1. 程式人生 > 實用技巧 >用redis解決多使用者同時編輯同一條資料問題

用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方案,友好又能解決需求,比較輕量級。

如有問題,歡迎交流