1. 背景与问题
在分布式系统中,常见的业务场景(如秒杀、订单扣减、幂等处理、定时任务调度等)需要使用分布式锁来保证同一时刻只有一个实例/线程能执行关键逻辑。
使用Redis作为分布式锁中间件是最主流的选择之一(SETNX + EXPIRE 或 Redisson),但存在一个经典问题:
业务执行时间 > 锁过期时间 时,会出现以下致命问题:
锁被提前释放(过期)
后续线程误获取到锁
当前业务还在执行 → 导致并发安全被破坏(超卖、重复扣款、重复调度等)
痛点总结:
固定过期时间很难预测业务最长执行时间
业务抖动、网络延迟、GC暂停、慢查询等都会导致实际耗时不可控
手动续期代码侵入性强、容易遗漏、维护困难
2. 目标
设计一套高可用、高性能、业务无侵入的Redis分布式锁方案,满足以下核心要求:
锁自动续期(业务未完成就自动延长)
锁持有者才能续期(防止误续)
业务异常退出/宕机,锁能自动释放(不永久持有)
支持可重入(同一个线程/同一个业务可多次加锁)
性能损失可接受(<5ms 延迟增加)
3. 核心方案:Redisson风格看门狗(Watchdog)自动续期机制
3.1 整体架构
业务代码
↓
分布式锁接口(Lock / RLock)
↓
RedissonClient / 自研RedisLock
├─ Lua脚本加锁(原子性)
├─ 定时任务线程池(Watchdog)
│ ↓
└─ 后台自动续期(EXPIRE)
↓
Redis Server(Hash + Key)3.2 关键数据结构(以Redisson为例)
锁Key在Redis中实际存储为 Hash 结构(支持可重入):
Key: lock_key:order:12345
Type: Hash
Fields:
"client:thread-uuid:threadId" → "2" (重入次数)
"client:thread-uuid:threadId" → "5" (示例)
Expire: 30s(初始) → 由看门狗动态续期3.3 加锁流程(Lua原子脚本)
-- KEYS[1] = lock key
-- ARGV[1] = thread unique id (uuid:threadId)
-- ARGV[2] = internalLockLeaseTime (默认30s)
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
redis.call('hset', KEYS[1], ARGV[1], 1);
redis.call('pexpire', KEYS[1], ARGV[2]);
return 1;
end;
if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[1], 1);
redis.call('pexpire', KEYS[1], ARGV[2]);
return 1;
end;
return 0;3.4 自动续期核心:看门狗(Watchdog)机制
加锁成功后立即启动一个后台续期任务
续期间隔 = 锁过期时间 / 3(默认10s,如果锁30s)
每次续期把过期时间重新设置为 30s(可配置)
续期Lua脚本(同样原子)
-- KEYS[1] = lock key
-- ARGV[1] = internalLockLeaseTime (30s)
if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 1;
end;
return 0;看门狗生命周期:
加锁成功 → 创建并启动续期TimerTask
解锁成功 → 取消该TimerTask
客户端进程退出 → TimerTask自然停止 → 锁自动过期释放
3.5 解锁流程(Lua原子)
-- KEYS[1] = lock key
-- ARGV[1] = thread unique id
local counter = redis.call('hget', KEYS[1], ARGV[1]);
if counter == false then
return nil;
end
counter = tonumber(counter);
if counter <= 0 then
redis.call('del', KEYS[1]);
return nil;
end
counter = counter - 1;
redis.call('hset', KEYS[1], ARGV[1], counter);
if counter > 0 then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]);
return 1;
end4. 推荐技术选型对比(2025-2026视角)
结论:优先使用 Redisson,除非有极特殊需求(如完全去Redisson依赖、极致性能裁剪)才考虑自研。
5. 最佳实践与注意事项
锁Key设计规范:
lock:biz:resource:{id},避免热点业务最坏情况超时设置:建议锁默认30s,业务尽量控制在10s内
看门狗线程池隔离:建议单独线程池,避免业务线程池阻塞影响续期
监控指标必备:
锁获取成功率 / 失败率
锁等待时长 P99
续期失败次数(理论上应≈0)
锁被意外释放次数(报警)
降级策略:分布式锁失败 → 是否降级为单机锁 / 异步补偿 / 人工干预
环境差异:开发环境可关闭看门狗或调大过期时间
6. 总结
通过看门狗自动续期机制,我们可以在不侵入业务代码的前提下,有效解决“业务未完成锁已过期”的致命问题,成为目前分布式锁事实标准(Redisson实现最为完善)。
推荐在所有新项目中默认使用Redisson RLock,并做好监控与Key规范,即可获得较高可靠性的分布式锁能力。
如需极致定制或完全自研,可参考上述Lua脚本 + TimerTask续期模式自行实现,但需承担更高的维护成本与风险。
7.业务场景
以下是针对真实业务场景(秒杀、支付回调、定时任务)定制的 Redis分布式锁方案(基于Redisson看门狗机制)
场景一:秒杀(高并发抢购 / 限购 / 库存扣减)
业务特点
QPS 极高(单商品峰值可达数万~数十万)
执行时间抖动大(Redis、数据库、第三方风控等)
超卖/少卖代价极高
锁粒度通常到 SKU 或 用户+SKU
推荐方案
// 推荐写法(启用看门狗)
RLock lock = redissonClient.getLock("lock:seckill:sku:" + skuId);
try {
// 建议等待时间 500~1500ms,根据实际压测调整
// -1 表示不限等待(但生产慎用,易堆积)
boolean acquired = lock.tryLock(800, TimeUnit.MILLISECONDS);
if (!acquired) {
// 抢锁失败 → 返回“商品已抢光”或进入排队/降级逻辑
return SeckillResult.fail(SeckillCode.OUT_OF_STOCK_OR_BUSY);
}
// 核心业务逻辑
// 1. 读库存(lua 或 pipeline)
// 2. 判断是否还有库存 & 用户限购
// 3. 扣减库存(Lua原子)
// 4. 记录抢购成功订单(异步MQ)
Long stock = decrementStockLua(skuId, buyQuantity);
if (stock < 0) {
return SeckillResult.fail(SeckillCode.OUT_OF_STOCK);
}
// 异步发MQ创建订单
orderEventProducer.sendCreateOrderEvent(userId, skuId, ...);
return SeckillResult.success();
} catch (Exception e) {
log.error("秒杀异常", e);
// 可选:补偿性回滚(视幂等设计)
return SeckillResult.fail(SeckillCode.SYSTEM_ERROR);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}关键配置建议
lockWatchdogTimeout = 30000 ~ 45000 ms
tryLock等待时间:500~2000ms(太长会导致请求堆积)
库存扣减使用 Lua 脚本 保证原子性(不依赖分布式锁内数据库事务)
热点商品可考虑 本地锁 + Redis锁 二级过滤
场景二:支付回调(异步通知 / 幂等处理)
业务特点
回调时间不可控(渠道方延迟可达几秒~几分钟)
必须严格幂等(重复回调不能重复入账)
锁粒度通常为 订单号 / 支付流水号
并发不高,但一致性要求极高
推荐方案
// 支付回调接口(Controller / MQ Listener)
public void onPayCallback(PayNotifyDTO dto) {
String lockKey = "lock:pay:order:" + dto.getOrderNo();
RLock lock = redissonClient.getLock(lockKey);
try {
// 支付回调等待时间可以稍长(渠道重试间隔通常较长)
if (!lock.tryLock(3, 45, TimeUnit.SECONDS)) {
log.warn("支付回调获取锁失败,稍后重试 orderNo:{}", dto.getOrderNo());
// 可选择:NACK / 重新入队 / 记录延迟处理
return;
}
// 检查是否已处理(防幂等核心)
if (isOrderAlreadyPaid(dto.getOrderNo())) {
log.info("订单已支付,幂等返回 orderNo:{}", dto.getOrderNo());
return;
}
// 核心处理
boolean success = processPayment(dto);
if (success) {
updateOrderStatusPaid(dto.getOrderNo(), dto.getTradeNo());
// 发MQ通知下游(发货、积分、券等)
}
} catch (Exception e) {
log.error("支付回调处理异常", e);
// 可记录到死信 / 人工审核
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}关键配置建议
lockWatchdogTimeout = 45000 ~ 90000 ms(回调处理可能涉及对账、清结算等长流程)
tryLock 等待时间:3~10秒(渠道回调重试间隔一般 >10s)
幂等判断优先于锁(锁仅防止并发处理同一订单)
场景三:定时任务(防止重复调度 / 集群幂等执行)
业务特点
多实例部署(k8s / 集群)
要求同一时刻只有一个实例执行
执行时间不确定(可能卡住、FullGC、慢SQL等)
推荐方案
// 使用 Redisson 的 RLock(而非 RSemaphore / RCountDownLatch)
@Scheduled(cron = "0 0/5 * * * ?") // 每5分钟
public void executeClearExpiredCouponTask() {
String lockKey = "lock:task:clear_expired_coupon";
RLock lock = redissonClient.getLock(lockKey);
if (!lock.tryLock()) {
log.info("定时任务-清理过期优惠券 获取锁失败,本次跳过");
return;
}
try {
log.info("开始清理过期优惠券...");
couponService.clearExpiredCoupons(LocalDateTime.now().minusDays(30));
log.info("清理过期优惠券完成");
} catch (Exception e) {
log.error("清理过期优惠券任务异常", e);
// 可选:告警
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}关键配置建议
lockWatchdogTimeout = 60000 ~ 180000 ms(根据任务最坏情况设置)
tryLock() 无等待(抢不到直接放弃本次执行)
任务Key建议加上环境标识(dev/test/prod)或业务线
推荐使用 Redisson Lock + Spring Task 组合,而非 ShedLock(更灵活)
对比表
希望以上方案能直接用于生产落地。如需针对具体中间件版本、Lua脚本完整示例、压测数据、Grafana看板模板等进一步补充,请随时告知。
(完)