Redis 分布式锁——从 SETNX 到 Redisson / Redis Distributed Locks from SETNX to Redisson
📅 创建时间:2026-06-02 🏷️ 标签:#Redis #分布式锁 #SETNX #Redisson #RedLock #Watchdog #Lua脚本 📚 前置知识:[[00-redis-overview]](Redis 全景) [[01-redis-architecture]](架构分析) [[03-redis-cache-patterns]](缓存三剑客) 📚 相关知识:[[/03-web/05-backend-engineering/04-distributed-system]](分布式锁原理) [[/03-web/07-middleware/01-redis-deep]](已有分布式锁内容) [[/03-web/05-backend-engineering/08-concurrency]](并发编程)
场景:10 万人同时抢 100 部手机,怎么保证不超卖
┌─────────────────────────────────────────────────────────────┐
│ │
│ 秒杀场景:10 万人同时抢 100 部手机。 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 方案 A:数据库乐观锁(行锁) │ │
│ │ UPDATE products SET stock = stock - 1 │ │
│ │ WHERE id = 1 AND stock > 0; │ │
│ │ │ │
│ │ 问题:10 万 QPS 同时打 MySQL │ │
│ │ → 事务排队 → 数据库连接耗尽 → 系统崩溃 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 方案 B:Redis 分布式锁 │ │
│ │ SET lock:product:1 unique_id NX EX 30 │ │
│ │ │ │
│ │ → 抢到锁的人去买手机 → 买完释放锁 │ │
│ │ → 没抢到锁的人直接返回"已售罄" │ │
│ │ → 只有极少数人进数据库,扛住 10 万 QPS │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 分布式锁是 Redis 最强大的武器之一。 │
│ 但用错了,比不用还危险。 │
│ │
└─────────────────────────────────────────────────────────────┘第1节:分布式锁的四大核心问题
问题全景图
┌─────────────────────────────────────────────────────────────┐
│ 分布式锁的四大灵魂拷问 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 问题 1:怎么获取锁? │
│ → 多进程同时抢,谁先抢到算谁的 │
│ │
│ 问题 2:怎么释放锁? │
│ → 别人持有锁时不能删,删了别人的锁怎么办 │
│ │
│ 问题 3:锁过期了但业务还没跑完? │
│ → 自动续期(Watchdog) │
│ │
│ 问题 4:同一进程需要多次获取锁? │
│ → 可重入锁 │
│ │
│ 任何一个问题没处理好,都会导致锁失效。 │
│ 这就是分布式锁"看起来简单,用起来处处是坑"的原因。 │
│ │
└─────────────────────────────────────────────────────────────┘灵魂拷问一:怎么获取锁
┌─────────────────────────────────────────────────────────────┐
│ 获取锁的正确姿势 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ❌ 错误做法:先 SET 再 EXPIRE(两步骤,有竞态) │
│ │
│ r.set("lock", "123") # 步骤1:设值 │
│ r.expire("lock", 30) # 步骤2:设过期 │
│ │
│ 问题:如果步骤1和2之间进程崩溃了 │
│ → 锁没有过期时间 → 永远不过期 → 死锁! │
│ │
│ ✅ 正确做法:SET NX EX(单指令,原子性) │
│ │
│ r.set("lock:product:1", lock_id, nx=True, ex=30) │
│ │
│ nx=True:key 不存在才设置(原子性保证) │
│ ex=30:30 秒后自动过期(防止死锁) │
│ │
│ 为什么 value 要用唯一 ID(UUID)? │
│ → 用于释放锁时验证"只有持有者才能释放" │
│ │
└─────────────────────────────────────────────────────────────┘灵魂拷问二:怎么释放锁
┌─────────────────────────────────────────────────────────────┐
│ 释放锁的正确姿势 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ❌ 错误做法:直接 DEL 删除锁 │
│ │
│ r.delete("lock:product:1") │
│ │
│ 问题场景: │
│ T1: 进程 A 获取锁(lock_id="A-uuid") │
│ T2: 进程 A 开始执行任务... │
│ T3: 任务太慢,锁自动过期了 │
│ T4: 进程 B 获取了同一把锁(lock_id="B-uuid") │
│ T5: 进程 A 任务执行完毕,执行 DEL(删掉了 B 的锁!) │
│ T6: 进程 C 获取了同一把锁(lock_id="C-uuid") │
│ T7: 进程 B 和 C 同时在执行同一个任务 → 并发问题! │
│ │
│ ✅ 正确做法:Lua 脚本,只删除自己的锁 │
│ │
│ lua = """ │
│ if redis.call('get', KEYS[1]) == ARGV[1] then │
│ return redis.call('del', KEYS[1]) │
│ else │
│ return 0 │
│ end │
│ """ │
│ r.eval(lua, 1, "lock:product:1", "A-uuid") │
│ │
│ T5: 进程 A 执行 Lua → GET 发现不是自己的 uuid → 不删 │
│ → 进程 B 的锁安全 ✅ │
│ │
└─────────────────────────────────────────────────────────────┘灵魂拷问三:锁过期了但业务没跑完
┌─────────────────────────────────────────────────────────────┐
│ 锁续期(Watchdog)问题 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 问题场景: │
│ T1: 进程 A 获取锁(30 秒过期) │
│ T2: 进程 A 开始执行任务(预计 60 秒) │
│ T3: T1+30s,锁自动过期了 │
│ T4: 进程 B 获取了同一把锁 │
│ T5: 进程 A 和 B 同时在执行 → 并发问题! │
│ │
│ 解法:锁续期(看门狗机制) │
│ │
│ Redisson 的 Watchdog: │
│ • 锁持有后,后台线程每 10 秒检查一次 │
│ • 如果持有者还在运行,自动续期 30 秒 │
│ • 只有主动释放锁或进程崩溃时才真正释放 │
│ │
│ lua = """ │
│ if redis.call('get', KEYS[1]) == ARGV[1] then │
│ return redis.call('expire', KEYS[1], ARGV[2]) │
│ else │
│ return 0 │
│ end │
│ """ │
│ # 续期:将过期时间重置为 30 秒 │
│ r.eval(lua, 1, lock_key, lock_id, 30) │
│ │
│ ⚠️ 注意:如果你不用 Redisson,手动续期需要额外线程。 │
│ │
└─────────────────────────────────────────────────────────────┘灵魂拷问四:可重入锁
┌─────────────────────────────────────────────────────────────┐
│ 可重入锁——同一进程多次获取 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景:方法 A 获取锁 → 调用方法 B,方法 B 也要获取同一把锁 │
│ │
│ 普通锁:同一进程两次 acquire → 死锁(自己等自己释放) │
│ 可重入锁:同一进程可以多次获取,只有计数归零才真正释放 │
│ │
│ 实现思路:用 Hash 存 {lock_id: 重入计数} │
│ │
│ lua_acquire = """ │
│ local key = KEYS[1] │
│ local id = ARGV[1] │
│ local ttl = ARGV[2] │
│ │
│ -- 如果锁不存在,初始化 │
│ if redis.call('exists', key) == 0 then │
│ redis.call('hset', key, id, 1) │
│ redis.call('expire', key, ttl) │
│ return 1 │
│ end │
│ │
│ -- 如果是自己持有的锁,重入计数 +1 │
│ if redis.call('hget', key, id) then │
│ redis.call('hincrby', key, id, 1) │
│ redis.call('expire', key, ttl) │
│ return 1 │
│ end │
│ │
│ return 0 │
│ """ │
│ │
│ lua_release = """ │
│ local key = KEYS[1] │
│ local id = ARGV[1] │
│ │
│ if redis.call('hget', key, id) then │
│ local cnt = redis.call('hincrby', key, id, -1) │
│ if cnt <= 0 then │
│ redis.call('del', key) │
│ end │
│ return 1 │
│ end │
│ return 0 │
│ """ │
│ │
└─────────────────────────────────────────────────────────────┘第2节:分布式锁完整实现
从零实现一个分布式锁
python
import redis
import uuid
import time
import threading
import json
class RedisLock:
def __init__(self, redis_client, key, timeout=30, blocking=True, blocking_timeout=10):
self.redis = redis_client
self.key = f"lock:{key}"
self.timeout = timeout
self.blocking = blocking
self.blocking_timeout = blocking_timeout
self.lock_id = str(uuid.uuid4())
self.acquired = False
self._renew_timer = None
def acquire(self):
"""尝试获取锁"""
end = time.time() + self.blocking_timeout
while time.time() < end:
# SET NX EX:原子获取锁
if self.redis.set(self.key, self.lock_id, nx=True, ex=self.timeout):
self.acquired = True
# 开启 watchdog 续期
self._start_watchdog()
return True
if not self.blocking:
return False
time.sleep(0.01) # 10ms 重试
return False
def release(self):
"""释放锁(只释放自己持有的)"""
if not self.acquired:
return
self._stop_watchdog()
lua = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
self.redis.eval(lua, 1, self.key, self.lock_id)
self.acquired = False
def _start_watchdog(self):
"""后台线程每 timeout/3 秒自动续期"""
def renew():
while self.acquired:
time.sleep(self.timeout / 3)
if self.acquired:
lua = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
"""
self.redis.eval(lua, 1, self.key, self.lock_id, self.timeout)
self._renew_timer = threading.Thread(target=renew, daemon=True)
self._renew_timer.start()
def _stop_watchdog(self):
self.acquired = False
def __enter__(self):
self.acquire()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.release()
return False
# 使用方式
def seckill(product_id):
lock = RedisLock(r, f"product:{product_id}", timeout=30, blocking_timeout=5)
if lock.acquire():
try:
stock = r.get(f"stock:{product_id}")
if stock and int(stock) > 0:
r.decr(f"stock:{product_id}")
create_order(product_id)
finally:
lock.release()
else:
return "已售罄"Redisson 的封装——开箱即用
java
import org.redisson.Redisson;
import org.redisson.api.RLock;
RedissonClient redisson = Redisson.create();
// 获取锁
RLock lock = redisson.getLock("order:123");
try {
// 尝试获取(等 10 秒,锁自动 30 秒过期)
boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (acquired) {
// 执行业务逻辑
processOrder(123);
}
} finally {
// 释放锁(自动处理 watchdog 续期)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
// Redisson 的优势:
// 1. Watchdog 自动续期(后台线程每 10 秒检查一次)
// 2. 可重入锁(同一线程多次获取)
// 3. 公平锁(按请求顺序排队)
// 4. 读写锁(读读不互斥,读写/写写互斥)第3节:RedLock——多节点多数派共识
为什么需要 RedLock
┌─────────────────────────────────────────────────────────────┐
│ 普通分布式锁的致命弱点 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 单机 Redis + 主从复制: │
│ │
│ T1: 进程 A 从 Master 获取锁(lock_id="A") │
│ T2: Master 把命令同步给 Slave(异步,有延迟) │
│ T3: Master 宕机,Slave 升主(锁数据还没同步过来!) │
│ T4: 进程 B 从新 Master 获取同一把锁(成功了!) │
│ T5: 进程 A 和 B 同时在执行 → 并发问题! │
│ │
│ 核心问题: │
│ 主从异步复制 → 主挂了可能丢锁 │
│ │
│ 解法:RedLock——多台 Redis 独立获取锁,多数派胜出 │
│ │
└─────────────────────────────────────────────────────────────┘RedLock 算法
┌─────────────────────────────────────────────────────────────┐
│ RedLock 算法流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 假设有 5 台 Redis 节点(N=5): │
│ │
│ 1. 获取当前时间 T1(毫秒级) │
│ │
│ 2. 依次向 5 台 Redis 请求获取锁: │
│ for i in [1, 2, 3, 4, 5]: │
│ r.set(lock_key, lock_id, nx=True, ex=30) │
│ 记录每台响应时间 │
│ │
│ 3. 计算锁的有效时间: │
│ valid_time = 30 - (T2 - T1) - 时钟偏移 │
│ → 如果获取了 ≥3 台,且 valid_time > 0 → 锁有效 │
│ │
│ 4. 如果锁有效: │
│ → 实际有效时间 = valid_time │
│ → 执行任务 │
│ │
│ 5. 任务完成后,向所有节点释放锁: │
│ for i in [1, 2, 3, 4, 5]: │
│ r.eval(release_lua) │
│ │
│ 6. 如果获取的节点 < 3: │
│ → 释放所有已获取的锁 │
│ → 锁获取失败 │
│ │
│ 为什么至少 3 台? │
│ → 允许 2 台宕机,剩下 3 台 多数派胜出 │
│ │
└─────────────────────────────────────────────────────────────┘RedLock 的争议和局限性
┌─────────────────────────────────────────────────────────────┐
│ RedLock 的争议和适用边界 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 争议点(Martin Kleppmann 等人 vs Antirez): │
│ │
│ 1. 时钟偏移问题: │
│ 5 台机器时钟不同步,可能导致有效时间计算错误 │
│ → RedLock 认为时钟偏移可忽略,争议方认为这是隐患 │
│ │
│ 2. 持锁期间逻辑复杂: │
│ 如果 GC(垃圾回收)导致进程暂停,锁可能已过期 │
│ → GC 安全点/STW 可能超过锁的有效时间 │
│ │
│ 适用边界: │
│ • 不需要强一致性的场景 → 单机 Redis + 主从足够 │
│ • 需要高可用且容忍短暂不一致 → RedLock │
│ • 强一致性(金融场景)→ Zookeeper / etcd │
│ │
│ 一句话:RedLock 不是银弹,它的正确性有争议。 │
│ 对于大多数业务场景,单机 Redis + 主从 + watchdog 就够了。 │
│ │
└─────────────────────────────────────────────────────────────┘第4节:常见踩坑指南
┌─────────────────────────────────────────────────────────────┐
│ 分布式锁十大踩坑清单 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 坑 1:锁的粒度设错了 │
│ ❌ SET lock order:* NX EX 30 │
│ → 所有订单共用一把锁 → 并发全变串行 │
│ ✅ SET lock order:123 NX EX 30 │
│ → 按具体资源加锁,只锁冲突的部分 │
│ │
│ 坑 2:锁的 TTL 设太短 │
│ ❌ 任务要 60 秒,但锁只设了 30 秒 │
│ ✅ 评估任务耗时 + 留 buffer;或用 watchdog 自动续期 │
│ │
│ 坑 3:业务代码写在 finally 外面 │
│ ❌ if lock.acquire(): process(); lock.release() │
│ ✅ try: lock.acquire(); process(); finally: lock.release()│
│ │
│ 坑 4:用 SETNX 做锁,但没验证返回值 │
│ ❌ r.setnx("lock", "id") → 没判断是否成功就执行业务 │
│ ✅ if r.set("lock", "id", nx=True, ex=30): process() │
│ │
│ 坑 5:释放锁时没检查是否是自己的 │
│ ❌ r.delete("lock") │
│ ✅ 用 Lua 脚本验证 lock_id 后再删除 │
│ │
│ 坑 6:主从切换丢锁 │
│ ❌ 单机 Redis + 主从 → Master 挂了可能丢锁 │
│ ✅ 对一致性要求高 → RedLock 或换 etcd/Zookeeper │
│ │
│ 坑 7:用 redis-py 的 lock() 对象,但忘了加 timeout │
│ ❌ lock = redis.lock("lock") → 没设 blocking_timeout │
│ ✅ lock = redis.lock("lock", timeout=30, blocking_timeout=10)│
│ │
│ 坑 8:锁等待时用了 sleep 而不是事件驱动 │
│ ❌ while not lock: time.sleep(0.1) │
│ ✅ 使用 redis-py 的 blocking=True 参数,不占用 CPU │
│ │
│ 坑 9:分布式锁 + 数据库事务混用 │
│ ❌ 获取锁 → 开启 DB 事务 → 提交事务 → 释放锁 │
│ → 如果 DB 事务卡住,锁也卡住,最后过期 → 并发问题 │
│ ✅ 获取锁 → 执行 DB 操作 → 释放锁 → 事务在锁内完成 │
│ │
│ 坑 10:集群 slot 迁移时抢锁 │
│ ❌ Cluster 迁移期间,同一 key 可能同时在两个节点存在 │
│ ✅ 使用 Redisson 的 RClusteredRedissonClient │
│ │
└─────────────────────────────────────────────────────────────┘升华:分布式锁不是万能的
┌─────────────────────────────────────────────────────────────┐
│ 什么时候用分布式锁,什么时候不用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用分布式锁的场景: │
│ ✅ 秒杀扣库存(减少数据库压力) │
│ ✅ 防止重复下单(幂等性保证) │
│ ✅ 任务调度(同一时刻只有一个节点执行) │
│ ✅ 分布式事务的前置锁(准备阶段) │
│ │
│ 不用分布式锁的场景: │
│ ❌ 数据一致性要求极高 → 换数据库行锁或 Zookeeper │
│ ❌ 只需要限制并发数 → 用信号量(Semaphore) │
│ ❌ 高频短任务 → 锁竞争激烈 → 考虑消息队列削峰 │
│ │
│ 一句话: │
│ 分布式锁是"让多进程串行执行某一小段代码"的工具。 │
│ 锁的粒度要尽量小,持有时间要尽量短。 │
│ 如果你的业务需要长时间持有锁,可能设计本身有问题。 │
│ │
└─────────────────────────────────────────────────────────────┘"AI 可查 vs 必须理解"清单
AI 可查:
✅ Redisson 各语言的 API 用法
✅ RedLock 算法的具体实现代码
✅ 各语言的 Redis 锁封装库(redisson-py / jedis-lock / redlock-py)
✅ Lua 脚本的语法和调试方法
必须理解:
🔴 SET NX EX 为什么比 SET + EXPIRE 更安全(原子性)
🔴 释放锁时为什么不能用 DEL 而要用 Lua 脚本验证
🔴 Watchdog 自动续期的原理和必要性
🔴 可重入锁的实现思路(Hash + 重入计数)
🔴 RedLock 的适用场景和局限性(不是银弹)
🔴 分布式锁的常见十大踩坑(特别是锁粒度和 TTL 设计)学习状态:🟡 开始学习