促销活动期间,有用户成功"秒杀"到 2 件库存只剩 1 件的商品,客服爆炸。复盘发现是分布式锁失效:Redis 主从切换的瞬间,新主上没有这把锁,两个进程同时进了临界区。本文把 Redis 分布式锁的 5 个常见错误实现讲清楚,然后给出 Redisson + Redlock 的正确用法,附完整代码。
事故场景
21:00:00.100 服务 A 用 SETNX 在 Redis master 上拿到 lock:item_999
21:00:00.150 Redis master 网络抖动,sentinel 选 slave 升级
21:00:00.200 Redis 新 master 上没有 lock:item_999(没复制过去)
21:00:00.250 服务 B SETNX lock:item_999 → 成功,也认为自己拿到锁
21:00:00.300 服务 A 和服务 B 同时 decr stock,导致 stock 减成 -1
核心问题:Redis 主从是异步复制,master 上 SETNX 成功但 slave 还没收到。一旦发生 failover,锁就丢了。
错误实现 1:setnx + expire 两步
// 错误:不原子,中间挂了导致永久锁
public boolean acquireLock(String key, String value, int ttlSec) {
Boolean ok = redis.opsForValue().setIfAbsent(key, value); // SETNX
if (Boolean.TRUE.equals(ok)) {
redis.expire(key, ttlSec, TimeUnit.SECONDS); // 这步可能没执行
return true;
}
return false;
}
如果 SETNX 之后进程崩了(GC pause / kill -9),expire 没执行,这把锁永远不会释放。
错误实现 2:setnx + 用 value 存过期时间
// 错误:存过期时间在 value 里,过期判断需要 GET,有竞态
public boolean acquireLock(String key, int ttlSec) {
long expireAt = System.currentTimeMillis() + ttlSec * 1000;
Boolean ok = redis.opsForValue().setIfAbsent(key, String.valueOf(expireAt));
if (Boolean.TRUE.equals(ok)) return true;
// 没拿到,看是不是过期了
String oldExpireStr = redis.opsForValue().get(key);
if (oldExpireStr != null && Long.parseLong(oldExpireStr) < System.currentTimeMillis()) {
String newExpireStr = String.valueOf(expireAt);
String prev = redis.opsForValue().getAndSet(key, newExpireStr);
// 竞态:两个线程同时走到这里,都会成功
// 实际只有第一个拿到锁,但代码无法区分
return prev != null && Long.parseLong(prev) < System.currentTimeMillis();
}
return false;
}
这种写法在《Redis 实战》里出现过,看着聪明实际有 bug。
正确实现 1:SET key value NX PX
// 单条命令,原子 setnx + expire
public boolean acquireLock(String key, String value, int ttlMs) {
Boolean ok = redis.execute((RedisCallback<Boolean>) c -> {
return c.set(key.getBytes(), value.getBytes(),
Expiration.milliseconds(ttlMs),
RedisStringCommands.SetOption.SET_IF_ABSENT);
});
return Boolean.TRUE.equals(ok);
}
// 释放锁必须用 Lua 保证"判断 + 删除"原子
public boolean releaseLock(String key, String value) {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redis.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key), value);
return result != null && result == 1;
}
这是最基础正确的实现:
SET NX PX一条命令搞定 setnx + 过期- 释放锁要传入持有时的 value,Lua 脚本保证 "GET + 比较 + DEL" 原子
- value 通常用 UUID 或 threadId,防止误删别人的锁
错误实现 3:不带 owner 的 release
// 错误:直接 DEL,可能把别人的锁删了
public void releaseLock(String key) {
redis.delete(key); // ← 万恶之源
}
// 场景:
// T1 拿到 lock,设置 30s 过期
// T1 业务执行 35s(超时,锁自动释放)
// T2 拿到同一个 lock(因为 T1 已过期)
// T1 业务执行完,调 releaseLock(key) → 把 T2 的锁删了
// T3 也能拿到锁 → 临界区两个人同时进
错误实现 4:用 Java Timer / ScheduledExecutor 续期
// 错误:看起来对,实际很多坑
public class BadAutoRenew {
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
public void acquireWithRenew(String key, String value, int ttlSec) {
if (acquireLock(key, value, ttlSec * 1000)) {
// 每 1/3 ttl 续期一次
scheduler.scheduleAtFixedRate(() -> {
renew(key, value, ttlSec); // ← 如果业务已经 release 了,这里还在续
}, ttlSec / 3, ttlSec / 3, TimeUnit.SECONDS);
}
}
}
需要在 release 时取消定时任务,需要处理续期失败,需要处理 release 之后续期仍然在跑的边界。每个细节都容易写错。
推荐实现:Redisson(生产首选)
Redisson 把上面所有细节都封装好,直接用:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.0</version>
</dependency>
@Service
public class SecKillService {
@Autowired private RedissonClient redisson;
@Autowired private InventoryRepo invRepo;
public OrderResult secKill(Long itemId, Long uid) {
RLock lock = redisson.getLock("lock:item:" + itemId);
try {
// tryLock(waitTime, leaseTime, unit)
// waitTime=0: 没拿到立即返回(适合秒杀,排队等没意义)
// leaseTime=-1: 用 watchdog 自动续期,30 秒一次
boolean locked = lock.tryLock(0, -1, TimeUnit.MILLISECONDS);
if (!locked) {
return OrderResult.fail("too_busy");
}
try {
Inventory inv = invRepo.findById(itemId);
if (inv.getStock() <= 0) return OrderResult.fail("sold_out");
invRepo.deduct(itemId, 1);
return OrderResult.ok();
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return OrderResult.fail("interrupted");
}
}
}
Redisson 内部细节:
- 用
SET NX PX加锁,带 UUID + threadId 作为 owner - WatchDog 后台线程每 10 秒续期到 30 秒(默认),客户端进程崩了自动停止续期
- release 用 Lua,判断 owner 才 unlock
- 支持可重入(hash 结构存重入次数)
- 提供 Pub/Sub 实现"等锁"语义
但 Redisson 单 master 仍有 failover 风险
这就是开头事故的根因。解决方案:Redlock(Redis 作者 antirez 提出的算法)。
Redisson 内置 Redlock 实现:
RLock lock1 = redisson1.getLock("lock:item:" + itemId);
RLock lock2 = redisson2.getLock("lock:item:" + itemId);
RLock lock3 = redisson3.getLock("lock:item:" + itemId);
RLock lock4 = redisson4.getLock("lock:item:" + itemId);
RLock lock5 = redisson5.getLock("lock:item:" + itemId);
RedissonMultiLock redLock = new RedissonMultiLock(lock1, lock2, lock3, lock4, lock5);
try {
boolean ok = redLock.tryLock(3, 30, TimeUnit.SECONDS);
if (ok) {
try {
// 临界区
} finally {
redLock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Redlock 的争议
Martin Kleppmann 写过著名的 How to do distributed locking 质疑 Redlock。核心论点:
- GC pause 风险:client 拿到锁后,JVM 长 GC 30 秒,锁过期了,client 还以为持锁,这时另一个 client 拿到了锁,两边同时操作
- 时钟漂移:Redlock 假设所有节点时钟差不多,实际容器化环境时钟不可靠
antirez 反驳:GC pause 这个问题任何分布式锁都有,不是 Redlock 独有。要彻底解决得用 fencing token。
// fencing token 思路:每次加锁拿到一个单调递增的 token
// 临界区操作时把 token 带给共享资源,资源校验 token 是不是最新
public Result secKill(Long itemId) {
long token = redisson.getAtomicLong("lock:item:" + itemId + ":token").incrementAndGet();
// ... 拿锁 ...
invRepo.deductWithFencing(itemId, 1, token); // SQL: WHERE last_token < ?
}
多数业务场景对一致性要求没到那种程度,Redisson + watchdog + 主从切换避免就够用。要追求 100% 正确性,得用 etcd / Zookeeper 这种 CP 系统的锁。
etcd 分布式锁(强一致)
import io.etcd.jetcd.*;
public Result secKillEtcd(Long itemId) throws Exception {
Client client = Client.builder().endpoints("http://etcd:2379").build();
Lock lockClient = client.getLockClient();
// 拿 lease,30s 过期
Lease leaseClient = client.getLeaseClient();
long leaseId = leaseClient.grant(30).get().getID();
// 加锁
String key = "/lock/item/" + itemId;
LockResponse lockResp = lockClient.lock(ByteSequence.from(key, UTF_8), leaseId).get();
String actualKey = lockResp.getKey().toString(UTF_8);
// KeepAlive 自动续期
StreamObserver<LeaseKeepAliveResponse> observer = leaseClient.keepAlive(leaseId, ...);
try {
// 临界区
return processOrder(itemId);
} finally {
observer.onCompleted();
lockClient.unlock(ByteSequence.from(actualKey, UTF_8)).get();
leaseClient.revoke(leaseId);
}
}
etcd 用 Raft,强一致,不会丢锁。代价:吞吐量比 Redis 低一个数量级(Redis ~10 万 QPS vs etcd ~5000 QPS 锁请求)。
选型建议
业务场景 推荐方案
=======================================================
内部限流 / 防重复点击 Redisson 单节点(够用)
普通业务幂等(订单创建) Redisson 哨兵/集群
秒杀 / 库存扣减(高一致要求) Redisson + 业务层乐观锁 fencing
支付 / 金融账务(强一致 + 不能丢) etcd / Zookeeper 锁 + DB 事务
团队规范
- 禁止直接用 redis SETNX/EXPIRE 两步写法
- 所有分布式锁统一走封装好的 LockTemplate(内部用 Redisson)
- 锁的 key 命名规范:
lock:{业务名}:{资源id} - 锁超时时间业务方必须显式声明,不允许默认无限等待
- 所有临界区操作前必须 doublecheck(防止锁过期被别人重入)
- 强一致场景必须配合 fencing token 或乐观锁版本号
分布式锁是看起来简单实际深坑的东西。Redis 的 SETNX 只是基础设施,工程上还得叠加 watchdog、Redlock、fencing token、监控等等。生产用 Redisson 加业务层兜底是最划算的方案,既不至于像 etcd 一样吞吐量惨,也不至于像裸 SETNX 一样写一堆 bug。
—— 别看了 · 2026