老规矩,来一个比喻,大家看看恰当不,欢迎来到分布式锁领域的**"角斗场"------**
在这里,两位大人物曾进行过一场艰巨的论战:
- 反方:Martin Kleppmann
- 正方:Salvatore Sanfilippo
争论的焦点只有一个:**Redlock 算法到底能不能在极端故障下保证锁的安全性?**今天,我们不站队,只谈底层、谈原理、谈工程取舍。努力把 Redlock 扒开了揉碎了,看看它到底是解决主从切换痛点的"圣杯",还是过度设计的"鸡肋"。
核心痛点:单点 Redis 的"阿喀琉斯之踵" 啊哈
咱们知道在生产环境中,Redis 通常是 主从集群 (Master-Slave) 部署的。
主从切换导致的锁丢失
-
加锁 :客户端 A 向 Master 节点成功写入锁
lock:order:1。 -
异步复制 :Master 准备将数据同步给 Slave。注意:Redis 的复制是异步的!
-
故障发生:就在数据还没同步到 Slave 的瞬间,Master 宕机了。
-
选举上位 :Sentinel 或 Cluster 机制触发,Slave 被提升为新的 Master。
-
悲剧 :新 Master 的数据里没有
lock:order:1! -
并发冲突 :客户端 B 向新 Master 申请锁,成功拿到锁。
-
结果 :A 和 B 同时持有锁,互斥性彻底失效。超卖、数据错乱随之而来
单点Redis:只有一个保安队长。他把钥匙给了你,然后突然晕倒了。新来的队长(Slave)完全不知道刚才发生过什么,又把钥匙给了另一个人。
后果:两个人都拿着钥匙进了同一个房间。
AP 模型 (Availability + Partition tolerance) 的代价:为了高可用,牺牲了强一致性
Redlock 算法:用"投票"换取"一致"
对此, antirez 提出了 Redlock 算法;不要依赖单个节点,要依赖"多数派"
算法五步走 (The 5 Steps)
部署了 N=5 个独立的 Redis 实例(注意:是独立的,不是主从关系,每个都是 Master)
-
获取当前时间 :客户端记录开始时间
T_start。 -
依次尝试加锁 :客户端按顺序(或并行)向 5 个实例发起加锁请求。
- 命令:
SET resource_name my_random_value NX PX 30000 - 关键点:必须设置一个较短的超时时间(如自动释放时间),防止某个节点挂死导致客户端无限等待。
- 命令:
-
统计成功数 :计算有多少个节点成功返回了加锁结果。
- 成功条件 :成功节点数
>= N/2 + 1(即 5 个中至少 3 个成功)。
- 成功条件 :成功节点数
-
验证有效性 :
- 计算耗时
T_elapsed = T_now - T_start。 - 锁有效条件 :
T_elapsed < 锁的有效期 (TTL)。 - 如果耗时太长,说明网络太慢或节点太卡,即使凑够了票数,锁也可能已经过期了,视为失败。
- 计算耗时
-
失败清理 :
- 如果加锁失败(票数不够 或 超时),客户端必须向所有 5 个节点发送 释放锁 指令(即使有些节点根本没加上),以清理残留状态。
Redlock = 公司有 5 个保安队长,各自独立保管一把钥匙的副本。
你想进门,必须至少获得 3 个队长 的同意(给你盖章)。
哪怕其中 2 个队长晕倒了(宕机),或者其中 2 个队长糊涂了(数据不一致),只要剩下的 3 个清醒的队长同意了,你的进门许可就是有效的。
安全性:因为总共只有 5 个人,不可能同时有两拨人各自凑齐 3 票(3+3=6 > 5)。这就是数学上的互斥保证。
世纪论战:Martin vs. Antirez
Martin Kleppmann 的质疑 (The Critic)
Martin 发表了著名文章《How to do distributed locking》,指出了 Redlock 的致命弱点:
- 时钟漂移 (Clock Drift) :
- Redlock 依赖客户端本地时间计算锁的有效期 (
TTL - elapsed)。 - 如果客户端机器时间跳变(如 NTP 同步导致时间回退),或者不同节点时间不同步,可能导致锁在客户端认为"有效"时,在服务端其实已经"过期"了。
- Redlock 依赖客户端本地时间计算锁的有效期 (
- GC 停顿 (Stop-The-World) :
- 如果客户端在拿到锁后,发生了长时间 GC 停顿。
- 停顿期间,锁在 Redis 端已经过期释放了。
- 其他客户端拿到了锁。
- 客户端 GC 结束醒来,以为自己还持有锁 ,继续操作临界区。-> 互斥性失效。
- 注:这个问题 Redisson 的看门狗也解决不了,因为看门狗线程也会被 GC 停住。
- 异步刷盘 (Async fsync) :
- 如果 Redis 节点配置了异步刷盘,节点宕机重启后,内存中的锁数据可能丢失。这会导致"多数派"中实际有效的节点数不足。
Antirez 的反击 (The Defender)
- 概率极低:他认为时钟漂移和 GC 停顿同时发生的概率极低,对于大多数互联网业务是可以接受的。
- fencing token (围栏令牌) :他承认单纯靠锁不够,建议在业务层引入 Fencing Token (每次加锁返回一个递增的版本号),在操作资源(如写数据库)时带上这个版本号,资源方拒绝处理旧版本号的请求。
- 这其实是把一致性压力转移到了业务资源层(如数据库)
业界真相:为什么大厂慎用 Redlock
阿里、美团、字节等大厂的核心生产环境 中,极少直接使用原生的 Redlock 算法
- 性能损耗巨大 :
- 单次加锁需要网络交互 N 次 (至少 5 次)。
- 延迟 =
Max(Network_Latency) * N。 - 在高并发秒杀场景下,这会成为巨大的瓶颈,QPS 直接掉一个数量级。
- 运维复杂度极高 :
- 需要维护 5 个完全独立的 Redis 实例(不能是主从,必须是物理隔离的 Master)。
- 这增加了基础设施成本和管理难度。
- 依然无法解决 GC 问题 :
- 正如 Martin 所说,JVM 的 STW 是语言层面的问题,Redlock 救不了。
- "杀鸡用牛刀" :
- 对于 99% 的业务,"单点 Redis + 数据库乐观锁兜底" 已经足够安全且高效。为了那 0.01% 的极端情况,引入 Redlock 的复杂性和性能代价,ROI (投入产出比) 太低。
架构师洞察 :
"在大厂,可用性 (Availability) 往往优于 强一致性 (Consistency)。如果 Redis 集群挂了,宁愿暂时降级(返回'系统繁忙'),也不愿意为了强一致而让整个系统变得缓慢且复杂。"
替代方案:如果不信 Redlock,我们信什么
如果业务真的对一致性要求极高(如金融转账),或者担心 Redis 主从切换丢锁,我们有更成熟的组合拳。
方案 A:Redis (高性能) + 数据库乐观锁 (强兜底)
- 第一道防线:Redis 分布式锁(单机或哨兵模式)。抗住 99.9% 的并发流量,快速失败。
- 第二道防线 :数据库
UPDATE ... WHERE version = old_version。- 即使 Redis 锁丢了,两个线程同时进入 DB 层。
- 线程 A:
UPDATE stock SET num=num-1, version=v+1 WHERE id=1 AND version=v(影响行数 1,成功)。 - 线程 B:
UPDATE ... WHERE version=v(影响行数 0,失败,抛出异常或重试)。
- 优点:既有了 Redis 的高性能,又有了 DB 的强一致性(ACID)。
- 缺点:DB 有写压力,但通常扣减库存的 QPS 远小于读 QPS,可接受。
方案 B:ZooKeeper / Etcd (CP 模型)
- 适用:并发量不大,但对一致性要求极高的场景(如金融核心、元数据管理)。
- 原理:利用 ZK 的 CP 特性,天然解决主从切换丢数据问题。
- 缺点:性能不如 Redis,运维成本高。
方案 C:数据库唯一索引 (Unique Index)
- 适用:防重提交、幂等性控制。
- 原理 :插入一张
lock_table,(biz_id)设为唯一索引。 - 优点:绝对可靠,依托 DB 事务。
- 缺点:性能最差,不适合高频短锁
我们系统业务代码------简版
那肯定不能把真的代码放出来,让大家"鞭尸"
@Autowired
private RedissonClient redissonClient;
@Autowired
private ProductMapper productMapper;
public void buyProductSafe(String productId, int userId) {
RLock lock = redissonClient.getLock("lock:product:" + productId);
boolean isLocked = false;
try {
// 1. 尝试获取 Redis 锁 (启用看门狗)
// 这里的目的是"限流"和"减少 DB 冲突",而不是绝对的互斥
isLocked = lock.tryLock(5, -1, TimeUnit.SECONDS);
if (!isLocked) {
throw new BusinessException("排队人数过多,请稍后重试");
}
// 2. 执行数据库乐观锁更新
// 即使 Redis 锁失效,这里也能保证数据一致性
int affectedRows = productMapper.deductStockWithVersion(productId, 1);
if (affectedRows == 0) {
// 更新失败,说明库存不足或版本号不匹配(并发冲突)
throw new BusinessException("库存不足或并发冲突,请重试");
}
// 3. 创建订单...
} catch (Exception e) {
// 异常处理
throw e;
} finally {
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
// Mapper XML 示例
// UPDATE product_stock
// SET stock = stock - 1, version = version + 1
// WHERE id = #{id} AND stock >= 1 AND version = #{oldVersion}
// 注意:实际场景中通常不需要查旧 version,直接用 stock >= 1 判断即可,
// 因为 stock 本身就是原子递减的。
// 更简单的写法:
// UPDATE product_stock SET stock = stock - 1 WHERE id = #{id} AND stock > 0
- 在这个方案中,Redis 锁的作用是**"漏斗"**,过滤掉大部分并发请求,减轻数据库压力。
- 数据库的
WHERE stock > 0(或 version 校验) 是**"铁闸"**,保证最终数据的绝对正确。 - 即使 Redis 主从切换导致锁丢失,最多也就是多几个请求打到数据库,由数据库的原子性来决出胜负,绝不会超卖。
彩蛋:
Q1: 请简述 Redlock 算法的原理,以及它解决了什么问题
- Redlock 是为了解决 Redis 主从架构下,Master 宕机导致锁数据未同步到 Slave,进而引发锁丢失的问题。
- 它通过部署 N 个独立的 Redis 实例,要求客户端必须获得 N/2 + 1 个实例的加锁成功响应,并验证总耗时小于锁有效期,才认为加锁成功。
- 利用"多数派"原则保证任意时刻只有一个客户端能凑齐票数,从而实现互斥。
Q2: Martin Kleppmann 对 Redlock 的主要批评是什么
- 时钟漂移:依赖客户端本地时间计算有效期,若时间不同步或跳变,可能导致锁安全性失效。
- GC 停顿:客户端长时间 GC 停顿会导致锁在服务端已过期,但客户端醒来后仍认为自己持有锁。
- 异步刷盘 :节点宕机重启后,若数据未持久化,会导致"多数派"计数虚高。
他建议引入 Fencing Token 机制,在资源层(如 DB)做最终校验
Q3:在生产环境中,你会选择 Redlock 吗?为什么?
回答 :通常不会首选 Redlock 。
原因:
- 性能差:网络交互次数多,延迟高,不适合高并发。
- 复杂度高:需维护多个独立实例。
- 性价比低 :对于绝大多数业务,"Redis 锁 + 数据库乐观锁兜底" 方案既能满足高性能,又能通过 DB 的 ACID 特性保证最终一致性,且实现简单、容错性强。
只有在极特殊场景(如无法使用 DB 兜底的纯内存操作,且对一致性要求极高)下,才会考虑 Redlock 或直接转向 ZooKeeper/Etcd。
Q4: 如果必须用 Redis 实现强一致性锁,除了 Redlock 还有什么办法?
- Redis + 数据库乐观锁(最推荐)。
- Redisson 的 MultiLock (注意:这只是逻辑组合,底层若仍是主从,依然有丢锁风险,除非底层是多主)。
- 迁移到 CP 模型中间件:如 ZooKeeper 或 Etcd。这是最彻底的解决方案。