用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方案,友好又能解決需求,比較輕量級。
如有問題,歡迎交流