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 集群的主从同步可能发生的宕机问题。