学完这篇,你能自己写出一个不会误删别人锁的 Redis 分布式锁,搞清 Redlock 到底解决了什么问题、又有什么解决不了,并在 Java 项目里用 Redisson 正确落地。
第一步:先把单机锁写对
分布式锁的起点是这一条命令,而不是 SETNX 加 EXPIRE 两条:
SET lock:order:1001 <随机token> NX PX 10000
NX 保证只有一个人能设成功,PX 10000 给了 10 秒自动过期,防止进程崩溃后锁永不释放。返回值是 OK 才算拿到锁。
释放时必须校验 value,否则会删掉别人的锁。用 Lua 保证原子性:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
PHP 里大致这样:
$token = bin2hex(random_bytes(16));
$ok = $redis->set($key, $token, ['nx', 'px' => 10000]);
// 业务处理
$redis->eval($luaScript, [$key, $token], 1);
注意:不要在业务里用「先 GET 判断是不是自己的锁,再 DEL」两步走。判断完到删除之间锁可能刚好过期并被别人拿走,你删的就是别人的锁。
第二步:理解 Redlock 想解决什么
单实例 Redis 锁有个明显问题:主从异步复制。你写入主库拿到锁,主库还没同步就宕机,从库升主,锁信息丢了,第二个人也能拿到锁。
Redlock 的思路是使用多个独立的 Redis master(通常 5 个,不部署主从)。加锁流程:
- 记录开始时间戳
- 依次向 N 个节点执行
SET key token NX PX ttl
- 只有在 N/2+1 个节点成功,并且总耗时小于锁有效期,才算拿到锁
- 没凑够多数派,就向所有节点发释放请求回滚
- 锁的实际有效期 = ttl - 加锁总耗时 - 时钟漂移补偿
代价是复杂度上来了:要维护 5 个独立实例,任何一次加锁都要串行打 5 次网络请求。
注意:Redlock 有一个持续多年的争议。Martin Kleppmann 指出,如果客户端在持有锁时发生长时间 GC 停顿或时钟跳变,锁过期后它自己并不知道,仍会继续写数据,多数派也挡不住。Redlock 无法提供严格的「互斥安全」。如果你的场景不允许两个写入同时发生,就用 fencing token(单调递增序号,让存储端拒绝旧序号)或者干脆用数据库唯一约束/行锁兜底。
第三步:Redisson 里怎么写
Java 项目基本不用手搓,Redisson 已经封装好了:
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("order:1001");
lock.lock(); // 不传参,走看门狗自动续期
try {
// 业务逻辑
} finally {
lock.unlock();
}
它的几个关键点:
- 看门狗续期:只有
lock() 无参调用时才会启动,默认锁 30 秒,后台每 10 秒续一次,只要进程还活着锁就不会过期。
- 可重入:同一线程重复
lock() 只是把计数加一,unlock() 相应减一。
- 联锁:
new RedissonMultiLock(lock1, lock2, ...) 就是 Redlock 的工程实现,要求所有锁都拿到才返回。
- 公平锁:
getFairLock() 按请求顺序发锁,适合有排队要求的场景。
多节点 Redlock 写法:
RLock l1 = redisson.getLock("lock:a");
RLock l2 = redisson.getLock("lock:b");
RLock l3 = redisson.getLock("lock:c");
RedissonMultiLock multiLock = new RedissonMultiLock(l1, l2, l3);
multiLock.lock();
try { /* ... */ } finally { multiLock.unlock(); }
注意:写成 lock(10, TimeUnit.SECONDS) 就关掉了看门狗,锁到 10 秒后强制失效,业务没跑完就会被人抢走。除非你能准确估算业务耗时,否则用无参 lock()。
第四步:几个必踩的坑
- unlock 抛
IllegalMonitorStateException:通常是锁已超时释放,或者当前线程不是持有者。加个 if (lock.isHeldByCurrentThread()) 再释放。
- 锁里做远程调用:锁持有时间被网络抖动放大,续期也救不了吞吐。锁内只放必要的临界区。
- 锁粒度过粗:
lock:order 会把所有订单串行化,正确写法是 lock:order:{订单号}。
- 把锁当幂等用:锁只防并发,不防重试。消息重投时仍需业务侧幂等键。
放到实际站点上也是同理。像 Clara BBS 这类系统在单机时,计划任务用文件锁就够了;一旦扩到多台机器,文件锁跨不了机器,同一时刻两台都在跑定时任务,这时才需要引入 Redis 锁。它后台「系统工具 → 缓存设置」已经可以可视化开启 Redis,缓存层在 Redis 异常时会降级到文件驱动,而锁不能降级;锁拿不到就该等或失败,这两种能力的定位要分清。
小结
- 加锁一条命令:
SET key token NX PX ttl,释放用 Lua 校验 token 后删除。
- token 用随机值,绝不用固定字符串。
- Redlock 靠多节点多数派 + 耗时扣除降低主从丢锁风险,但挡不住长 GC 停顿导致的「僵尸持锁」,强一致场景要加 fencing token 或数据库约束。
- Redisson 优先用无参
lock() 享受看门狗续期,传了过期时间就没了这层保护。
- 锁粒度按业务主键切分,锁内不做远程调用,unlock 前判断持有者。