用redis解決多用戶同時編輯同一條數據問題


1,場景再現

場景:總公司可以給分公司下發今年的規划任務(可能只是寫了個規划大綱),分公司收到后,進行詳細的規划補充,然后提交。

比如規划表:

CREATE TABLE `sys_plan` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `branch_offince_id` int(11) DEFAULT NULL COMMENT '分公司id',
  `head_office_plan` varchar(255) DEFAULT NULL COMMENT '總公司規划',
  `branch_office_plan` varchar(255) DEFAULT NULL COMMENT '分公司規划',
  `create_time` datetime DEFAULT NULL COMMENT '創建時間',
  `update_time` datetime DEFAULT NULL COMMENT '修改時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

為了簡化業務場景,這里用兩個字段:總公司規划、分公司規划模擬。

比如總公司給分公司A新建的規划,填寫在總公司規划字段(head_office_plan),分公司收到消息后進行補充,填寫在分公司規划(branch_office_plan)字段。

可能出現的問題的場景:

1,總公司用戶A,給某分公司B新建了一條規划: 1,銷售額1000萬;2,生產產品2萬件

此時數據庫數據是這樣的:

2,分公司收到消息提醒,登錄了系統,查看到總公司派發的任務,頁面是這樣的:

然后陷入沉思,思考該怎么填寫自己的規划.

3,此時總公司想再補充一條規划,就登錄系統,打開頁面,編輯head_office_plan字段:3,員工規模擴充到100人,然后提交了。此時頁面是這樣的。

數據庫是這樣的:

4,分公司想好了怎么填寫規划,此時總公司補充的規划,開始填寫:1,提高生產效率,2,...

由於分公司在總公司提交補充規划3之前就打開了頁面,所以規划3這里是不顯示的。

然后,問題就出現了,分公司把總公司的規划 3,員工規模擴充到100人 這條規划給覆蓋成空的了。數據庫中現在是這樣的:

PS:

其他的如政務系統,用戶體系有國家級別、地方級別,像這種用戶體系有上下級關系的管理系統,上下級更可能操作同一條數據,更可能出現這種情況,其他對於C端用戶的系統,我們編輯的一般都是編輯自己的資源,不會出現這種場景。

2,要達到的目標

如果某條數據正在被編輯,另一個人也要編輯該數據,就給出友好提示“某某某正在編輯該數據,請稍后重試”,或者是直接就不能查看。

3,解決方案

網上的方案:

方案1

在操作的表里添加一個version字段數值類型的默認0,只要對數據進行了操作就對version加1,每一次頁面操作(刪除、修改)都先判斷version是否和打開時的version值一樣,如果不一樣請先刷新,在進行操作

方案2:

在數據表里添加一個UUID字段,其值為32位的隨機數。
1.記錄新建時,在數據提交后台,插入DB之前,生成UUID,保存之。
2.記錄編輯時,在編輯頁面將UUID隱藏,提交時Check該隱藏值是否與DB一致。
不一致則返回前台畫面,報對應的Message;
一致則提交后台,生成新UUID,與業務數據一起保存到表中

這兩種方案弊端,只能是讓第二個想編輯的人刷新頁面,重新填寫。

牛總公司的方案:

數據庫加字段,比如加一列,is_edit,當有人編輯的時候,設置is_edit=1,編輯完成后設置is_edit=0,其他人再查詢該條數據,查看is_edit是否=1,如果是就給出提示;但是,如果第一個人打開頁面進行編輯,設置了is_edit=1,然后他把瀏覽器關了,is_edit就=1了,此時誰也編輯不了了,所以這種方案不可取。不知道他們怎么處理這種關閉瀏覽器的。

終極redis方案

所以,我們討論的方案是,用redis做。采用類似用redis做分布式鎖的思路,來解決並發編輯問題。

用redis的SETNX 命令: 設置成功,返回 1 , 設置失敗,返回 0 。

原理:

以 lock_plan_{planId} 為redis的key,userId為value,某個用戶在獲取plan的時候,先用 lock_plan_{planId}往redis設置值,如果返回false,說明這個資源已經有人加了鎖了,返回失敗。

定義一個公共資源鎖的服務類:

提供3個方法:

1,獲得鎖:當獲取某條數據的同時,先去獲得鎖(鎖設定一個有效期,這個有效期根據業務定,頁面內容多就多設置一些,內容少就設置短一點,設置有效期保證長時間不操作,不會死鎖),如果獲取鎖成功,就查詢那條數據,否則返回提示。

2,釋放鎖:當成功獲取了某條數據時,進行編輯后,update操作之后,釋放鎖。讓等待的人可以正常獲取鎖。

3,延續鎖的時長:當用戶操作某條數據持續時間較長,前端設置一個心跳,定時調用此接口延續鎖的有效期,類似與redission的自動續期鎖時長。這個心跳時間間隔,根據業務定,小於鎖的有效期,比如設置為1/3 鎖的時長,鎖的延期間的時長,自己定,比如1/3有效期(redission好似也是1/3有效期)。

/**
 * 公共資源鎖服務
 * create by lihaoyang on 2020/8/17
 */
public interface CommonLockResourceService {


    /**
     * 獲得鎖
     * @param resourceKeyPrefix 鎖的redis前綴
     * @param resourceId 資源id
     * @param userId 用戶id
     * @return
     */
    boolean getLock(String resourceKeyPrefix,int resourceId,int userId);


    //釋放鎖
    boolean unLock(String resourceKeyPrefix,int resourceId,int userId);

    //鎖延期
    boolean resetLock(String resourceKeyPrefix,int resourceId, int userId);
}

實現類: 主要要確保鎖的可重入性,同一個用戶多次加鎖,要獲得同一把鎖。

@Service
@Transactional
public class CommonLockResourceServiceImpl implements CommonLockResourceService {



    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean getLock(String resourceKeyPrefix, int resourceId, int userId) {

        String lock = resourceKeyPrefix + resourceId;

        //如果該userId已經有該項目的鎖,鎖續期
        if(StringUtils.equals(""+userId,stringRedisTemplate.opsForValue().get(lock.intern()))){
            //鎖的可重入
            long ttl = stringRedisTemplate.getExpire(lock);
            //續期時間,自己定
            stringRedisTemplate.expire(lock.intern(),ttl+60L, TimeUnit.SECONDS);
            return true;
        }
        //枷鎖
        boolean isLock = stringRedisTemplate.opsForValue().setIfAbsent(lock.intern(),userId+"",60L,TimeUnit.SECONDS);
        return isLock;

    }

    @Override
    public boolean unLock(String resourceKeyPrefix, int resourceId, int userId) {
        String lock = resourceKeyPrefix + resourceId;
        if((userId+"").equals(stringRedisTemplate.opsForValue().get(lock.intern()))) {
            stringRedisTemplate.delete(lock.intern());
            return true;
        }
        return false;
    }

    @Override
    public boolean resetLock(String resourceKeyPrefix, int resourceId, int userId) {
        String lock = resourceKeyPrefix + resourceId;
        //如果該userId已經有該項目的鎖,鎖續期
        if(StringUtils.equals(""+userId,stringRedisTemplate.opsForValue().get(lock.intern()))){
            long ttl = stringRedisTemplate.getExpire(lock);
            stringRedisTemplate.expire(lock.intern(),ttl+60L,TimeUnit.SECONDS);
            return true;
        }
        return false;
    }
}

Controller:

@RestController
@RequestMapping("/sysPlan")
public class SysPlanController {

    static final String lockKeyPrefix = "lock_plan_";

    @Autowired
    private SysPlanService planService;

    @Autowired
    private CommonLockResourceService commonLockResourceService;


    //~============= redis鎖 ================
    @GetMapping("/getByIdLock")
    public Result getByIdLock(@RequestParam int planId, @RequestParam int userId){
        //TODO:userId應該從session獲取而不是傳過來
        boolean isLock = commonLockResourceService.getLock(lockKeyPrefix,planId,userId);
        if(isLock){
            SysPlan plan = planService.getById(planId);
            return Result.ok(plan);
        }
        //還可以獲取到誰在編輯,如果需要的話
        return Result.error("當前規划正在編輯中,請稍后重試");
    }


    @GetMapping("/update")
    public Result update(@RequestParam int planId,@RequestParam int userId){
        //這里應該放在service層
        //update By Id
        //planService.updateById();
        boolean isRelease = commonLockResourceService.unLock(lockKeyPrefix,planId,userId);
        return isRelease?Result.ok():Result.error("釋放鎖失敗");
    }

    @GetMapping("/resetLock")
    public Result resetLock(@RequestParam int planId,@RequestParam int userId){

        boolean success = commonLockResourceService.resetLock(lockKeyPrefix,planId,userId);
        return success?Result.ok():Result.error("釋放鎖失敗");
    }


}


4,實驗

數據庫數據:

1,用戶一(userId=101),前端通過plan_id查詢某條規划:

localhost:8888/sysPlan/getByIdLock?planId=1&userId=101

返回成功:

{
    "message": "成功",
    "code": 200,
    "result": {
        "id": 1,
        "branchOffinceId": 1,
        "headOfficePlan": "xxasdaaaaaaa",
        "branchOfficePlan": "1,提高生產效率",
        "createTime": null,
        "updateTime": "2020-08-17T08:27:36.000+0000"
    },
    "timestamp": 1597658245474
}

2,用戶二(userId=102),嘗試獲取該資源。(這里直接傳入不同userId代表不同用戶)

localhost:8888/sysPlan/getByIdLock?planId=1&userId=102

返回:

{
    "message": "當前項目正在編輯中,請稍后重試",
    "code": 500,
    "result": null,
    "timestamp": 1597659012412
}

3,如果用戶一(userId=101)編輯這條數據持續的時間較長(可能是一個文本域,輸入很多文本),前端做一個定時器,定時調用延續鎖時長接口,在操作期內,使自己一直拿到當前的鎖,防止操作沒完成,鎖被釋放了,別人拿到了鎖。

localhost:8888/sysPlan/resetLock?planId=1&userId=101

返回:

{
    "message": "成功",
    "code": 200,
    "result": null,
    "timestamp": 1597659371463
}

4,用戶一(userId=101)編輯完成,提交編輯,主動釋放鎖。

localhost:8888/sysPlan/update?planId=1&userId=101,此時redis中的鎖被清除。

5,用戶二(userId=102)再次嘗試獲得數據

localhost:8888/sysPlan/getByIdLock?planId=1&userId=102

返回:

{
    "message": "成功",
    "code": 200,
    "result": {
        "id": 1,
        "branchOffinceId": 1,
        "headOfficePlan": "xxasdaaaaaaa",
        "branchOfficePlan": "1,提高生產效率",
        "createTime": null,
        "updateTime": "2020-08-17T08:27:36.000+0000"
    },
    "timestamp": 1597659558272
}

5,總結

用數據庫字段方案,有點“重”,需要不斷地維護這個字段,而且還有限制,用redis方案,友好又能解決需求,比較輕量級。
補充:項目業務還有其他場景,比如要提示當前誰正在編輯該數據,如沒有此需求,用數據庫version字段比較合適。

如有問題,歡迎交流

歡迎關注個人公眾號交流學習:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM