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字段比较合适。

如有问题,欢迎交流

欢迎关注个人公众号交流学习: