Redis分布式锁的七个坑:看门狗、锁续期、Redlock算法争议
Redis分布式锁的七个坑:看门狗、锁续期、Redlock算法争议
适读人群:中高级Java工程师 | 阅读时长:约20分钟 | 技术栈:Spring Boot 3.x、Redisson、Redis Cluster
开篇故事
2023年3月,一个凌晨两点的告警把我从睡梦中吵醒。监控显示我们支付核心服务的错误率从 0.01% 突然飙到 8%,每分钟有约 400 笔支付回调处理失败,每一笔都是真实的用户资金。
赶到电脑前一看,错误日志全是"Lock wait timeout"——分布式锁等待超时。继续往下查,发现有一批订单被重复处理:同一笔订单的支付回调被两个实例同时执行,导致重复记账。
我们用的是自己封装的 Redis 分布式锁,乍看逻辑没问题:SET NX PX 30000,Lua 脚本释放。但出问题的场景我没预料到:某个实例处理一个订单时,因为下游风控系统突然变慢,业务逻辑执行到第 31 秒,锁已经过期了。另一个实例拿到锁开始处理,两个实例并行跑,最终重复记账。
这就是 Redis 分布式锁里最经典的问题之一:锁过期与业务未完成的时间差。那次事故之后,我把 Redis 分布式锁能踩的坑研究了一遍,今天一次性都说清楚。
一、核心问题分析
Redis 分布式锁看起来很简单,一条 SET 命令就搞定了。但魔鬼藏在细节里,稍不注意就会在生产环境翻车。我整理了七个高频坑,其中有三个我亲身经历过,另外四个是团队其他人踩过或在其他公司案例中见过的。
这七个坑可以分为三大类:
锁安全性问题:删了别人的锁(坑一)、锁未持有就释放(坑二)。 锁活性问题:锁过期业务未完成(坑三)、看门狗续期失控(坑四)。 高可用问题:主从切换锁丢失(坑五)、Redlock 的争议(坑六)、Redis 重启(坑七)。
二、七个坑的原理解析
坑一:用错释放方式,删了别人的锁
这是最基础也最致命的错误。错误代码:
// 危险代码!GET 和 DEL 之间存在时间窗口
if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}时序如下:线程A持有锁,锁恰好过期。线程B抢到锁,写入自己的 lockValue。线程A执行 GET,拿到的是线程B的 lockValue,equals 比较失败——但如果这里存在线程切换,线程A刚拿到值还没比较,线程B的锁又过期,线程C拿到锁……总之,GET 和 DEL 不是原子的,一定会出问题。
正确做法:Lua 脚本,GET + DEL 原子执行。
坑二:加锁失败但仍然执行业务
// 错误写法:没有检查加锁结果
redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);
doBusinessLogic(); // 不管加没加上锁都执行了!这种错误一般是代码 review 不到位造成的,但后果很严重。记得有一次代码合并时出现了这个 bug,在测试环境没压出来,上线后并发一高就出现数据重复。setIfAbsent 返回 Boolean,必须检查。
坑三:锁过期,业务还没完成
这就是我开篇说的那次事故的根因。设了 30 秒过期时间,业务在第 31 秒还没跑完,锁自动过期,另一个实例拿到锁,两个实例并行执行。
这个问题有两种解决思路:
思路一:精确评估业务最大耗时,过期时间设为最大耗时的 3-5 倍。这种方案简单,但无法应对外部系统突然变慢的场景(比如第三方接口超时从正常的 200ms 飙到 30s)。
思路二:看门狗自动续期。这就是 Redisson 的设计思路。
坑四:看门狗续期失控
Redisson 的看门狗机制:获取锁时如果没有设置 leaseTime(即不指定过期时间),Redisson 会启动一个后台线程(WatchDog),每隔 lockWatchdogTimeout/3 时间(默认每 10 秒)重置锁的过期时间为 lockWatchdogTimeout(默认 30 秒)。
这个机制本意是好的,但有个坑:如果业务线程卡死(死循环、死锁),看门狗会一直续期,锁永远不过期,其他节点永远获取不到锁。
解决方案:对于有明确最大执行时间的业务,务必显式指定 leaseTime,禁用看门狗。只有对无法预估执行时间的业务才启用看门狗,同时在业务逻辑中加超时控制。
坑五:主从切换导致锁丢失
Redis 主从架构下,写操作先写 Master,再异步复制到 Slave。如果 Master 刚接受了加锁请求,还没来得及同步,就宕机了,Sentinel 会将 Slave 晋升为新 Master,这个新 Master 上没有这把锁,其他节点就能再次加锁成功,出现两个节点同时持有同一把锁。
对于绝大多数业务场景,这个概率极低(主从切换是秒级的,复制延迟通常在毫秒内),可以用业务层幂等性来兜底。对于资金等强一致性场景,要么用 Redlock,要么换 ZK 锁。
坑六:Redlock 算法的争议
Redis 作者 Antirez 提出了 Redlock 算法,用于解决主从切换的锁丢失问题。核心思路:向 N 个独立的 Redis 实例(N 推荐为 5)分别尝试加锁,如果超过半数(N/2+1)加锁成功,且总加锁时间小于锁的有效期,则认为加锁成功。
Redisson 实现了 Redlock:
RLock lock1 = redisson1.getLock("myLock");
RLock lock2 = redisson2.getLock("myLock");
RLock lock3 = redisson3.getLock("myLock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean locked = redLock.tryLock(500, 30000, TimeUnit.MILLISECONDS);但 Redlock 有一个著名的争议,由分布式系统专家 Martin Kleppmann 提出:即使 Redlock 保证了加锁的互斥,也无法保证锁的有效性。
原因在于 GC Stop-The-World:进程可能在任意时刻被 GC 暂停。假设进程A加锁成功,然后被 GC 暂停了 40 秒(极端情况下真的会发生),锁在 GC 期间过期,进程B加锁成功,开始执行业务。GC 结束后,进程A恢复,也在执行业务。此时两个进程都认为自己持有锁。
Antirez 对此有回应,认为这种场景下任何锁方案都解决不了,需要在业务层做 fencing token(单调递增的 token)来解决。
我的生产建议:不要把 Redlock 当成银弹。95% 的场景,Redis 主从锁 + 业务幂等性就够了。真正需要强一致性的场景,用 ZK 锁。
坑七:Redis 重启导致锁消失
Redis 设置了持久化,但 RDB/AOF 都有一定的数据丢失风险。如果 Redis 在持久化间隔内重启,内存中的锁数据会消失。
解决方案:对于强一致性场景,使用 AOF + appendfsync always(每次写操作都 fsync),但这会严重影响 Redis 性能。更实际的方案是接受这个风险,在业务层做幂等性兜底。
三、完整代码实现:生产级 Redis 锁
基于以上七个坑,我封装了一个生产可用的 Redis 分布式锁组件。
核心锁实现
@Component
@Slf4j
public class ProductionRedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
// Lua 脚本:原子释放锁
private static final String RELEASE_LOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
// Lua 脚本:原子续期(只有是自己的锁才续期)
private static final String RENEW_LOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('pexpire', KEYS[1], ARGV[2]) " +
"else " +
" return 0 " +
"end";
/**
* 加锁(不启用看门狗,必须指定过期时间)
*/
public LockResult tryLock(String lockKey, long expireMs) {
String lockValue = generateLockValue();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofMillis(expireMs));
if (Boolean.TRUE.equals(success)) {
return LockResult.success(lockKey, lockValue);
}
return LockResult.failure();
}
/**
* 加锁(启用看门狗,适用于执行时间不确定的业务)
* 注意:必须在业务完成后调用 releaseLock,否则看门狗永远续期
*/
public LockResult tryLockWithWatchdog(String lockKey, long expireMs) {
String lockValue = generateLockValue();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofMillis(expireMs));
if (Boolean.TRUE.equals(success)) {
// 启动看门狗线程
ScheduledExecutorService watchdog = startWatchdog(lockKey, lockValue, expireMs);
return LockResult.successWithWatchdog(lockKey, lockValue, watchdog);
}
return LockResult.failure();
}
private ScheduledExecutorService startWatchdog(String lockKey, String lockValue, long expireMs) {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "redis-lock-watchdog-" + lockKey);
t.setDaemon(true);
return t;
});
// 每隔 expireMs/3 续期一次
long renewInterval = expireMs / 3;
scheduler.scheduleAtFixedRate(() -> {
try {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(RENEW_LOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(lockKey),
lockValue, String.valueOf(expireMs));
if (!Long.valueOf(1L).equals(result)) {
log.warn("看门狗续期失败,锁可能已被释放,lockKey={}", lockKey);
scheduler.shutdown();
}
} catch (Exception e) {
log.error("看门狗续期异常,lockKey={}", lockKey, e);
}
}, renewInterval, renewInterval, TimeUnit.MILLISECONDS);
return scheduler;
}
/**
* 释放锁
*/
public boolean releaseLock(LockResult lockResult) {
if (!lockResult.isSuccess()) {
return false;
}
// 先停止看门狗
if (lockResult.getWatchdog() != null) {
lockResult.getWatchdog().shutdownNow();
}
DefaultRedisScript<Long> script = new DefaultRedisScript<>(RELEASE_LOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(lockResult.getLockKey()),
lockResult.getLockValue());
return Long.valueOf(1L).equals(result);
}
/**
* 带重试的加锁
*/
public LockResult tryLockWithRetry(String lockKey, long expireMs,
int maxRetry, long retryIntervalMs) {
for (int i = 0; i <= maxRetry; i++) {
LockResult result = tryLock(lockKey, expireMs);
if (result.isSuccess()) {
return result;
}
if (i < maxRetry) {
// 加随机抖动,避免惊群效应
long jitter = ThreadLocalRandom.current().nextLong(retryIntervalMs / 2);
try {
TimeUnit.MILLISECONDS.sleep(retryIntervalMs + jitter);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
return LockResult.failure();
}
private String generateLockValue() {
return UUID.randomUUID().toString().replace("-", "")
+ ":" + Thread.currentThread().getId();
}
}LockResult 数据类
@Getter
public class LockResult {
private final boolean success;
private final String lockKey;
private final String lockValue;
private final ScheduledExecutorService watchdog;
private LockResult(boolean success, String lockKey,
String lockValue, ScheduledExecutorService watchdog) {
this.success = success;
this.lockKey = lockKey;
this.lockValue = lockValue;
this.watchdog = watchdog;
}
public static LockResult success(String lockKey, String lockValue) {
return new LockResult(true, lockKey, lockValue, null);
}
public static LockResult successWithWatchdog(String lockKey, String lockValue,
ScheduledExecutorService watchdog) {
return new LockResult(true, lockKey, lockValue, watchdog);
}
public static LockResult failure() {
return new LockResult(false, null, null, null);
}
}注解式分布式锁(AOP 实现)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributedLock {
/** 锁 key,支持 SpEL 表达式,如 "#orderId" */
String key();
/** key 前缀 */
String prefix() default "lock:";
/** 锁过期时间(毫秒) */
long expireMs() default 10_000L;
/** 最大重试次数 */
int maxRetry() default 0;
/** 重试间隔(毫秒) */
long retryIntervalMs() default 100L;
/** 是否启用看门狗 */
boolean watchdog() default false;
}@Aspect
@Component
@Slf4j
public class DistributedLockAspect {
@Autowired
private ProductionRedisLock redisLock;
private final ExpressionParser parser = new SpelExpressionParser();
private final ParameterNameDiscoverer nameDiscoverer =
new DefaultParameterNameDiscoverer();
@Around("@annotation(distributedLock)")
public Object around(ProceedingJoinPoint joinPoint,
DistributedLock distributedLock) throws Throwable {
String lockKey = buildLockKey(joinPoint, distributedLock);
LockResult lockResult;
if (distributedLock.maxRetry() > 0) {
lockResult = redisLock.tryLockWithRetry(lockKey,
distributedLock.expireMs(),
distributedLock.maxRetry(),
distributedLock.retryIntervalMs());
} else if (distributedLock.watchdog()) {
lockResult = redisLock.tryLockWithWatchdog(lockKey, distributedLock.expireMs());
} else {
lockResult = redisLock.tryLock(lockKey, distributedLock.expireMs());
}
if (!lockResult.isSuccess()) {
throw new DistributedLockException("获取分布式锁失败:" + lockKey);
}
try {
return joinPoint.proceed();
} finally {
redisLock.releaseLock(lockResult);
}
}
private String buildLockKey(ProceedingJoinPoint joinPoint,
DistributedLock distributedLock) {
String keyExpression = distributedLock.key();
if (!keyExpression.contains("#")) {
return distributedLock.prefix() + keyExpression;
}
// 解析 SpEL 表达式
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = nameDiscoverer.getParameterNames(signature.getMethod());
Object[] args = joinPoint.getArgs();
EvaluationContext context = new StandardEvaluationContext();
if (paramNames != null) {
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
}
String keyValue = parser.parseExpression(keyExpression).getValue(context, String.class);
return distributedLock.prefix() + keyValue;
}
}使用注解:
@Service
public class OrderService {
// 对同一个订单加锁,10秒过期,不重试
@DistributedLock(key = "#orderId", prefix = "order:pay:", expireMs = 10_000)
public void processPayCallback(Long orderId, PayResult payResult) {
// 业务逻辑
}
// 对商品库存加锁,30秒过期,启用看门狗
@DistributedLock(key = "#productId", prefix = "stock:deduct:",
expireMs = 30_000, watchdog = true)
public void deductStock(Long productId, int count) {
// 可能执行时间较长的业务
}
}四、生产调优与配置
Redisson 生产配置
实际上,生产环境我更推荐直接用 Redisson,它比自己实现更可靠:
spring:
redis:
redisson:
config: |
singleServerConfig:
address: "redis://127.0.0.1:6379"
password: "your-password"
database: 0
connectionPoolSize: 64
connectionMinimumIdleSize: 10
idleConnectionTimeout: 10000
connectTimeout: 10000
timeout: 3000
retryAttempts: 3
retryInterval: 1500
lockWatchdogTimeout: 30000
codec: !<org.redisson.codec.JsonJacksonCodec> {}Redisson 锁使用:
@Service
@Slf4j
public class PaymentService {
@Autowired
private RedissonClient redissonClient;
public void processPayment(Long orderId) {
RLock lock = redissonClient.getLock("payment:lock:" + orderId);
// tryLock: 等待时间500ms, 锁超时10s
boolean locked;
try {
locked = lock.tryLock(500, 10_000, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("加锁被中断");
}
if (!locked) {
throw new BusinessException("支付处理中,请勿重复提交");
}
try {
// 业务逻辑
doProcessPayment(orderId);
} finally {
// isHeldByCurrentThread 防止释放别人的锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private void doProcessPayment(Long orderId) {
// 真实业务实现
}
}Redis 集群下的锁配置
spring:
redis:
redisson:
config: |
clusterServersConfig:
nodeAddresses:
- "redis://127.0.0.1:7001"
- "redis://127.0.0.1:7002"
- "redis://127.0.0.1:7003"
scanInterval: 2000
slots: 8192
readMode: SLAVE
loadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}
masterConnectionPoolSize: 64
slaveConnectionPoolSize: 64五、踩坑实录
坑一:看门狗和业务超时控制冲突
2023年我们有个批处理任务,使用看门狗加锁,任务里调用了一个不稳定的第三方 API。某天第三方 API 直接挂了,调用一直阻塞,看门狗不停续期,这个锁持续了将近 2 小时没有释放,导致后续的批处理任务全部排队等待,当天的日报数据整整延迟了 3 个小时才出来。
教训:使用看门狗一定要在业务代码里设置超时控制,不要让外部调用无限期阻塞。用 CompletableFuture.get(timeout, TimeUnit.SECONDS) 或 HttpClient 的连接超时来强制结束。
坑二:锁 key 设计错误导致全局锁
一个同事写代码时这样设计锁 key:
// 错误:所有用户用同一把锁,变成全局锁了!
String lockKey = "user:profile:update";本意是对每个用户的个人资料更新加锁,但因为 key 里没带用户ID,所有用户的更新都争同一把锁,并发直接从几千 QPS 降到单线程串行,接口 P99 从 50ms 飙到 3000ms。
正确写法:"user:profile:update:" + userId
坑三:未捕获异常导致锁未释放
这是一个老生常谈但确实发生过的问题:
// 危险代码:try 块外没有 finally
LockResult lockResult = redisLock.tryLock(key, 30_000);
if (!lockResult.isSuccess()) {
throw new BusinessException("系统繁忙");
}
doSomething(); // 如果这里抛出 RuntimeException,下面的 release 就执行不到
redisLock.releaseLock(lockResult); // 这行代码可能不会执行!正确写法永远是:
LockResult lockResult = redisLock.tryLock(key, 30_000);
if (!lockResult.isSuccess()) {
throw new BusinessException("系统繁忙");
}
try {
doSomething();
} finally {
redisLock.releaseLock(lockResult);
}这个 bug 在某次灰度发布中被发现,那个场景的业务逻辑在特定入参下会抛出 IllegalArgumentException,锁没有释放,30 秒内相同 key 的请求全部失败,影响了约 1200 个用户。
坑四:Redis 连接池耗尽导致加锁超时
并发高峰期,Redis 连接池满了,所有 tryLock 调用都在等待连接,等待超时抛异常,业务以为是锁被其他人持有,实际上根本没执行到 Redis。
这种情况下监控显示 Redis 健康、业务大量失败,排查方向容易走偏。解决方案:
- 合理配置连接池大小(
connectionPoolSize根据并发量评估) - 加监控:
RedissonClient暴露了连接池指标,接入 Micrometer - 加降级:获取锁失败时,对于非核心逻辑可以降级放行
六、总结
Redis 分布式锁的七个坑,核心教训汇总:
一是永远用 Lua 脚本保证原子性,SET NX + GET + DEL 这三步不能分开。 二是看门狗是把双刃剑,用了要配合业务超时控制,否则死锁比锁过期更麻烦。 三是Redlock 理论上更安全,工程上成本更高,大多数场景主从锁加业务幂等性就够了。 四是锁 key 设计要精细,粒度太粗变全局锁,性能灾难。 五是finally 释放锁是铁律,任何路径都要走到 finally。
分布式锁说到底,只是一个工具,用好了是保障,用错了是隐患。真正的可靠性,来自于锁 + 幂等 + 监控的三层防护体系。
