分布式锁是大型应用中最常见的功能之一,基于Redis实现分布式锁的方式有很多。本文先介绍并分析常见的分布式锁实现方式,之后结合阿里巴巴集团在使用云原生内存数据库Tair和分布式锁方面的业务经验,介绍使用Tair实现高性能分布式锁的实践方案。

分布式锁及其应用场景

应用开发时,如果需要在同进程内的不同线程并发访问某项资源,可以使用各种互斥锁、读写锁;如果一台主机上的多个进程需要并发访问某项资源,则可以使用进程间同步的原语,例如信号量、管道、共享内存等。但如果多台主机需要同时访问某项资源,就需要使用一种在全局可见并具有互斥性的锁了。这种锁就是分布式锁,可以在分布式场景中对资源加锁,避免竞争资源引起的逻辑错误。

分布式锁的特性

  • 互斥性

    在任意时刻,只有一个客户端持有锁。

  • 不死锁

    分布式锁本质上是一个基于租约(Lease)的租借锁,如果客户端获得锁后自身出现异常,锁能够在一段时间后自动释放,资源不会被锁死。

  • 一致性

    硬件故障或网络异常等外部问题,以及慢查询、自身缺陷等内部因素都可能导致Redis发生高可用切换,replica提升为新的master。此时,如果业务对互斥性的要求非常高,锁需要在切换到新的master后保持原状态。

使用原生Redis实现分布式锁

说明 该部分介绍的实现方式同样适用于云Redis社区版。
  • 加锁

    在Redis中加锁非常简便,直接使用SET命令即可。示例及关键选项说明如下:

    SET resource_1 random_value NX EX 5
    表 1. 关键选项说明
    参数/选项 说明
    resource_1 分布式锁的key,只要这个key存在,相应的资源就处于加锁状态,无法被其它客户端访问。
    random_value 一个随机字符串,不同客户端设置的值不能相同。
    EX 设置过期时间,单位为秒。您也可以使用PX选项设置单位为毫秒的过期时间。
    NX 如果需要设置的key在Redis中已存在,则取消设置。

    示例代码为resource_1这个key设置了5秒的过期时间,如果客户端不释放这个key,5秒后key将过期,锁就会被系统回收,此时其它客户端就能够再次为资源加锁并访问资源了。

  • 解锁

    解锁一般使用DEL命令,但可能存在下列问题。

    1. t1时刻,App1设置了分布式锁resource_1,过期时间为3秒。
    2. App1由于程序慢等原因等待超过了3秒,而resource_1已经在t2时刻被释放。
    3. t3时刻,App2获得这个分布式锁。
    4. App1从等待中恢复,在t4时刻运行DEL resource_1将App2持有的分布式锁释放了。

    从上述过程可以看出,一个客户端设置的锁,必须由自己解开。因此客户端需要先使用GET命令确认锁是不是自己设置的,然后再使用DEL解锁。在Redis中通常需要用Lua脚本来实现自锁自解:

    if redis.call("get",KEYS[1]) == ARGV[1] then
        return redis.call("del",KEYS[1])
    else
        return 0
    end
  • 续租

    当客户端发现在锁的租期内无法完成操作时,就需要延长锁的持有时间,进行续租(renew)。同解锁一样,客户端应该只能续租自己持有的锁。在Redis中可使用如下Lua脚本来实现续租:

    if redis.call("get",KEYS[1]) == ARGV[1] then
        return redis.call("expire",KEYS[1], ARGV[2])
    else
        return 0
    end

使用Tair实现分布式锁

使用Tair内存型持久内存型实例的String增强命令,无需Lua即可实现分布式锁。

  • 加锁

    加锁方式与原生Redis相同,使用SET命令:

    SET resource_1 random_value NX EX 5
  • 解锁

    直接使用Redis企业版的CAD命令即可实现优雅而高效的解锁:

    /* if (GET(resource_1) == my_random_value) DEL(resource_1) */
    CAD resource_1 my_random_value
  • 续租

    续租可以直接使用CAS命令实现:

    CAS resource_1 my_random_value my_random_value EX 10
    说明 CAS命令不会检查新设置的value和原value是否相同。

基于Jedis的示例代码

  • 定义CAS/CAD命令
    enum TairCommand implements ProtocolCommand {
        CAD("CAD"), CAS("CAS");
    
        private final byte[] raw;
    
        TairCommand(String alt) {
          raw = SafeEncoder.encode(alt);
        }
    
        @Override
        public byte[] getRaw() {
          return raw;
        }
    }
  • 加锁
    public boolean acquireDistributedLock(Jedis jedis,String resourceKey, String randomValue, int expireTime) {
        SetParams setParams = new SetParams();
        setParams.nx().ex(expireTime);
        String result = jedis.set(resourceKey,randomValue,setParams);
        return "OK".equals(result);
    }
  • 解锁
    public boolean releaseDistributedLock(Jedis jedis,String resourceKey, String randomValue) {
        jedis.getClient().sendCommand(TairCommand.CAD,resourceKey,randomValue);
        Long ret = jedis.getClient().getIntegerReply();
        return 1 == ret;
    }
  • 续租
    public boolean renewDistributedLock(Jedis jedis,String resourceKey, String randomValue, int expireTime) {
        jedis.getClient().sendCommand(TairCommand.CAS,resourceKey,randomValue,randomValue,"EX",String.valueOf(expireTime));
        Long ret = jedis.getClient().getIntegerReply();
        return 1 == ret;
    }

如何保障一致性

Redis的主从同步(replication)是异步进行的,如果向master发送请求修改了数据后master突然出现异常,发生高可用切换,缓冲区的数据可能无法同步到新的master(原replica)上,导致数据不一致。如果丢失的数据跟分布式锁有关,则会导致锁的机制出现问题,从而引起业务异常。下文介绍三种保障一致性的方法。

  • 使用红锁(RedLock)

    红锁是Redis作者提出的一致性解决方案。红锁的本质是一个概率问题:如果一个主从架构的Redis在高可用切换期间丢失锁的概率是k%,那么相互独立的N个Redis同时丢失锁的概率是多少?如果用红锁来实现分布式锁,那么丢锁的概率是(k%)^N。鉴于Redis极高的稳定性,此时的概率已经完全能满足产品的需求。

    说明 红锁的实现并非这样严格,一般保证M(1<M=<N)个同时锁上即可,但通常仍旧可以满足需求。

    红锁的问题在于:

    • 加锁和解锁的延迟较大。
    • 难以在集群版或者标准版(主从架构)的Redis实例中实现。
    • 占用的资源过多,为了实现红锁,需要创建多个互不相关的云Redis实例或者自建Redis。
  • 使用WAIT命令。

    Redis的WAIT命令会阻塞当前客户端,直到这条命令之前的所有写入命令都成功从master同步到指定数量的replica,命令中可以设置单位为毫秒的等待超时时间。在云Redis版中使用WAIT命令提高分布式锁一致性的示例如下:

    SET resource_1 random_value NX EX 5
    WAIT 1 5000

    使用以上代码,客户端在加锁后会等待数据成功同步到replica才继续进行其它操作,最大等待时间为5000毫秒。执行WAIT命令后如果返回结果是1则表示同步成功,无需担心数据不一致。相比红锁,这种实现方法极大地降低了成本。

    需要注意的是:

    • WAIT只会阻塞发送它的客户端,不影响其它客户端。
    • WAIT返回正确的值表示设置的锁成功同步到了replica,但如果在正常返回前发生高可用切换,数据还是可能丢失,此时WAIT只能用来提示同步可能失败,无法保证数据不丢失。您可以在WAIT返回异常值后重新加锁或者进行数据校验。
    • 解锁不一定需要使用WAIT,因为锁只要存在就能保持互斥,延迟删除不会导致逻辑问题。
  • 使用Tair
    • Tair的CAS/CAD命令可以极大降低分布式锁的开发和管理成本,提升锁的性能。
    • Tair内存型实例能提供三倍于原生Redis的性能,即使是大并发的分布式锁也不会影响正常的实例服务。
    • Tair持久内存型实例基于持久内存技术,掉电数据不丢失,每个写操作将在持久化成功之后返回,保证了数据的实时持久化。同时,持久内存版型实例还支持配置主备实例间同步方式为半同步,保证写入数据并同步至备节点后,才成功返回客户端(若出现备节点故障、网络异常等情况会降级为异步同步),保证高可用切换后数据不丢失。