1. 前言
对于一个在线运行的系统,如果需要修改数据库已有数据,需要先读取旧数据,再写入新数据。因为读数据和写数据不是原子操作,所以在高并发的场景下,关注的数据可能会修改失败,需要使用锁控制。
2. 分布式场景
2.1 分布式锁场景
面试官提问: 为什么要使用分布式锁?分布式锁解决了什么问题?
题目解析:
首先分析锁的应用场景,我们对于已有数据的修改可以归纳为两个动作:
(1)读旧数据;
(2)写新数据。
然后分析并发操作导致脏数据的过程:
对于并发执行的两次请求,两个请求同时读到旧数据值为 10,第一个请求执行操作后新值为 30,第二个请求执行操作后新值为 40,最终只有第二次请求成功写入数据实体,导致第一次请求失效。
在单机部署的系统中,我们可以直接使用本地的锁(例如 Java 的 Object 对象锁)解决上述的并发冲突问题,但是当服务器分布式部署时,单机的锁并不能跨网络调用,所以需要使用分布式锁解决问题。
2.2 Redis 分布式锁
面试官提问: 既然谈到了分布式锁的应用场景,在实战环境是如何实现分布式锁的呢?
题目解析:
目前分布式锁最主要有三种实现方式:
(1)基于 Redis 集群的模式;
(2)基于 Zookeeper 集群的模式;
(3)基于 DB 数据库的模式
本章节只关注 Redis 的部分,核心思路是通过 setnx 指令,实例:
public static void wrongWayLock(Jedis jedis, String prefix_key, String id, int expire_time) {
// 加锁
Long result = jedis.setnx(prefix_key, id);
if (result==1){
// 如果加锁成功,设置过期时间
jedis.expire(prefix_key,expire_time);
}
}
加锁步骤主要分为两步:
(1)通过 setnx 指令加锁,setnx 的含义是 set if not exist
,即如果 redis 不存在已有的 prefix_key ,则写入 prefix_key ,设置对应 value=id
,并且调用返回为 1,如果已有 prefix_key ,则不写入并且返回非 1.
(2)通过 expire 指令,设置过期时间,如果 prefix_key 代表的锁一直没有删除,则在定时后自动失效,防止产生死锁的情况。
上述代码并不完美,其中 setnx()
和 expire()
函数并不是原子操作,如果执行 setnx()
指令之后,redis 集群出现网络抖动或者在线服务本身异常,导致后续 expire()
指令并没有执行,prefix_key 代表的锁并没有被加上过期时间,还是有产生死锁的可能性,我们对上述代码进行改造,实例:
public static boolean setLock(Jedis jedis, String prefix_key, String id, int expire_time) {
if(jedis.set(prefix_key, id, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expire_time) == 1) {
return true; //加锁成功
}
return false; //加锁失败
}
这种方案是将加锁和设置过期时间合并为一个步骤,一次 set,是原子操作。另外还有诸多开源代码解决这个问题,例如通过开源 lua 脚本,基于 redis 集群进行改造。
既然有加锁的过程,就有操作执行结束之后释放锁的过程,实例:
public static void unLock(Jedis jedis, String prefix_key, String id){
//如果在集群中存在prefix_key的值,并且和之前配置的id相同
if(id.equals(jedis.get(prefix_key))){
//删除prefix_key键值对
jedis.del(prefix_key);
}
}
使用分布式锁都是为了应对高并发的场景,高并发场景下,上述代码存在严重的并发执行问题。
例如第一行 if 判断完成之后,其他线程已经提前进入条件判断并且执行了 del 操作,当前线程再执行 del 操作就不合理。
还是出现了没有保证操作原子性的问题,通用的解决方案是通过 lua 脚本的 eval()
函数,首先获取锁对应的 value(即我们的 id ),如果相等
才删除锁,lua 脚本能保证原子性,实例:
public boolean unlock(String prefix_key,String request){
//lua脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = jedis.eval(script, Collections.singletonList(prefix_key), Collections.singletonList(id));
if (result == 1){
return true ;
}
return false;
}
3. 小结
本章节介绍了使用 Redis 实现最基础的分布式锁问题,给出了满足原子性的加锁和解锁操作,需要候选人能够给面试官清晰解释两步操作的关注点。另外,本章节对于一些可能存在的问题没有给出具体解决方案,例如 prefix_key 经过超时时间后自动过期,但是业务还没有执行完成,以及 Redis 集群的主从同步可能发生的宕机问题。