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方案,友好又能解决需求,比较轻量级。
如有问题,欢迎交流