分布式锁

利用 Redis 的 SETNX 命令,SETNX 全称是 “SET if Not Exists”,意为仅当指定的键不存在时,才设置该键的值,多线程调用 SETNX 可以保证有一个线程可以执行成功。

有加锁就有解锁,解锁需要判断持有锁的是否是当前线程(避免释放错锁),然后再释放,这是两步操作,需要保证原子性,放在 LUA 脚本里。

释放错锁的例子:假设线程 A 持有锁,在锁未释放前因阻塞(如睡眠、网络延迟)超过锁的过期时间,锁被自动释放。此时线程 B 获取到该锁,若线程 A 恢复后直接解锁,会错误地释放线程 B 持有的锁,导致其他线程可能趁机抢占锁,引发并发安全问题(如数据不一致)。

分布式锁的 key 和 value 的设置也是有讲究的,针对不同的资源或业务场景,需要使用不同的 key。例如:

  • 操作用户 A 的订单时,key 可以是 lock:order:1001(1001 为订单 ID)。
  • 操作库存时,key 可以是 lock:stock:product:2002(2002 为商品 ID)。

value 标识 “持有锁的线程 / 客户端”,用于解锁时校验身份,防止误解锁(核心作用)。必须保证每个获取锁的客户端 / 线程的 value 唯一,通常使用:

  • 随机字符串(如 UUID、GUID)。
  • 客户端 ID + 线程 ID(如 clientId:threadId)。

Q:如果线程挂了锁怎么释放?

给锁加过期时间。

Q:线程执行业务,还没执行完,锁超时自动释放怎么办?

引入看门狗线程,定时给锁续时。

Q:业务线程挂了,看门狗线程一直续时怎么办?

把看门狗设置为守护线程,守护线程生命周期依赖于业务线程,业务线程挂了,守护线程也就终止了。

实现可重入锁?

首先为什么要实现可重入?第一,递归方法需要重复获取同一把锁,第二,如果两个方法需要获取同一把锁,且方法 A 调用方法 B,就会造成死锁。

如何实现可重入?参考 sychronized 和 AQS,sychronized 给每个对象都关联了一个锁监视器,监视器有一个计数器字段,重入一次计数器就加一次,释放一次就减一次,减到 0 锁就释放完毕了。AQS 同样的道理,有一个 state 字段,等价于 sychronized 的计数器字段。

Redis 实现可重入也需要一个计数器,可以利用 Hash 结构,key 也就是业务表示,field 存储持有锁的客户端唯一标识(线程 id + UUID),value存储该客户端的重入次数(整数)。Hash结构的 SETNX 命令是HSETNX,例如HSETNX lock:order:1001 "client:uuid" 1,这也是 Redisson 的底层实现方式。

为什么这里客户端唯一标识是 “线程 id + UUID” ?

因为我们一般不在线程内生成 UUID,UUID 在客户端启动时生成一次,客户端内的线程用线程 ID 来区分。

订阅通知机制

与订阅通知相对的是自旋锁,不断轮询消耗 CPU 资源,先订阅锁,锁释放时再通知订阅锁的线程,可以避免轮询。

Redis 的订阅通知机制(Publish/Subscribe, 简称 Pub/Sub)底层是基于内存中的订阅表结构 + 事件驱动的网络 I/O(基于 epoll/kqueue/select 的 Reactor 模型)来实现的。

Redis 内部有两张订阅表:

  1. pubsub_channelsdict 结构,key 是 channel 名字,value 是订阅该 channel 的客户端链表。
  2. pubsub_patternslist 结构,存放的是带通配符模式(比如 news.*)的订阅。

当客户端执行:

1
SUBSCRIBE news.sports

Redis 就会把这个客户端记录到 pubsub_channels["news.sports"] 的链表里。

当客户端执行:

1
PUBLISH news.sports "hello world"

Redis 的流程是:

  1. pubsub_channels 中找到 key = news.sports,拿到所有订阅它的客户端。
  2. 遍历这些客户端,把消息写入它们的输出缓冲区。
  3. 同时在 pubsub_patterns 中匹配所有模式订阅,如果 news.sports 符合,就同样发送消息。

注意:Redis 不会存储消息,只是即时推送。如果某个客户端当时断开了连接,它就收不到这条消息。

主从架构下的锁丢失问题

以下场景下就会造成锁丢失:

  1. 客户端 A 加锁成功:客户端 A 向主节点发送 SET lock_key clientA NX PX 30000,主节点执行成功,记录 “lock_key 的值是 clientA”,但还没把这个数据同步给从节点。
  2. 主节点突然宕机:主节点因为故障(比如断电、崩溃)下线,此时从节点还没收到 “lock_key 被 clientA 持有” 的同步数据。
  3. 主从切换:Redis 的高可用组件(比如哨兵、Redis Cluster)发现主节点宕机,会把其中一个从节点升级为新的主节点。
  4. 客户端 B 重复加锁:客户端 B 向新主节点发送加锁命令,新主节点查看自己的数据,发现 lock_key 不存在,就执行成功 —— 此时客户端 A 和 B 都持有了同一把锁,锁的互斥性被破坏,后续操作可能导致数据混乱(比如同时修改同一条订单)。

可以通过红锁解决:

Redlock 为了解决这个问题,申请上锁的时候不会针对同一个节点,而是面向多节点(通常为半数以上),只有同时上锁成功才认为可以获取到锁,只要有一个失败,其他已经上锁的都要进行释放,获取锁失败。

实际项目中不建议使用 Redlock 算法,成本和收益不成正比,可以考虑基于 Redis 主从复制+哨兵模式 实现分布式锁,虽然这样还是存在锁丢失的风险,但是主节点挂掉且在主从切换的间隙发生锁丢失的概率很低。