分类:架构与高并发 · 作者:管理员 · 发布于 2026-10-19 · 阅读 0 · 标签:Redis / 分布式锁 / Redisson / 高并发 / Spring Boot

一、痛点:单机锁在分布式环境下为什么失效

业务刚起步时,扣库存、领优惠券、生成订单号这些临界区用一个 synchronized 或 ReentrantLock 就搞定了。但服务一水平扩容到多实例,问题立刻暴露:JVM 锁只能锁住本进程,两个实例上的线程可以同时进入同一个临界区——库存超卖、优惠券被并发领取、订单号重复,事故往往就在扩容后的第一次大促。

很多人第一反应是「上 Redis 锁」,但分布式锁的坑比想象中多。本文从手动 SETNX 实现讲起,一步步演进到 Redisson 的生产级方案,最后重点讲最容易忽略的锁粒度设计。

二、手动实现:从 SETNX 到 Lua 原子释放的三阶段演进

第一阶段:SETNX 加锁、EXPIRE 设过期时间,两条命令分开执行。看着没问题,但 SETNX 成功之后、EXPIRE 执行之前进程宕机,锁就永远不会过期,直接死锁。

第二阶段:用单条原子命令 SET key value NX EX seconds,加锁和过期一并完成,解决死锁问题。但释放锁时用 DEL 会误删:线程 A 的锁超时自动释放后,线程 B 拿到锁,此时 A 才执行完业务回来 DEL,把 B 的锁删掉了,第三个线程趁虚而入。

第三阶段:释放前先比对 value(每个线程持有一个唯一 token),用 Lua 脚本保证「比对 + 删除」的原子性:

// 加锁:token 必须是线程唯一标识,如 UUID
Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent("lock:order:" + orderId, token, 30, TimeUnit.SECONDS);

// 释放:Lua 脚本原子校验并删除
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then "
        + "return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
        new DefaultRedisScript<>(lua, Long.class),
        Collections.singletonList("lock:order:" + orderId), token);
if (result != null && result == 1L) {
    // 释放成功
}

到这里手动实现「能用」了,但仍有三座大山:可重入要自己用 ThreadLocal 计数实现、锁续期要自己起定时任务、主从切换会丢锁。与其造轮子,不如直接用 Redisson——它把这些全部内置了。

三、Redisson 核心机制:看门狗、可重入与 tryLock

Redisson 的 RLock 默认启用看门狗(Watchdog):锁默认有效期 30 秒,后台每 10 秒自动续期到 30 秒,只要业务线程还活着锁就不会过期;如果进程宕机,锁最多 30 秒后自动释放,不会死锁。这一机制直接消灭了「锁超时设多少合适」的千古难题。

生产代码示例:

@Configuration
public class RedissonConfig {

    @Bean(destroyMethod = "shutdown")
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://redis-host:6379")
                .setPassword("******")
                .setConnectionPoolSize(32);
        return Redisson.create(config);
    }
}

// 业务代码:库存扣减
RLock lock = redissonClient.getLock("lock:sku:" + skuId);
boolean locked = false;
try {
    // waitTime 2 秒:拿不到锁最多等 2 秒
    // 不传 leaseTime:启用看门狗自动续期
    locked = lock.tryLock(2, TimeUnit.SECONDS);
    if (!locked) {
        throw new BizException("系统繁忙,请稍后重试");
    }
    // 临界区:查询库存、扣减、写流水
    int stock = stockMapper.selectStock(skuId);
    if (stock < buyCount) {
        throw new BizException("库存不足");
    }
    stockMapper.decreaseStock(skuId, buyCount);
} finally {
    if (locked && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

重点提醒:tryLock(waitTime, leaseTime, unit) 一旦显式传入 leaseTime,看门狗就失效,锁到期直接释放。生产上要么不传 leaseTime 靠看门狗续期,要么评估业务最大耗时后传一个足够大的 leaseTime,两种策略二选一,不要混用,否则会出现「以为在看门狗保护,其实锁已经过期」的错觉。

四、锁粒度设计:别一把锁锁全库

锁 key 的设计直接决定并发上限,这是新手最容易翻车的地方。

  • 全局锁:lock:inventory。所有 SKU 共抢一把锁,库存操作全部串行,吞吐量断崖式下跌,大促必崩。
  • 资源维度锁(推荐):lock:sku:{skuId}、lock:user:{userId}、lock:coupon:{couponId}。不同 SKU 的扣减互不阻塞,并发度 = 资源数量。
  • 业务组合锁:防止同一用户重复下单,用 lock:order:{userId}:{skuId} 或幂等号维度,兼顾防重与并发。

另一个原则:锁内代码越短越好。锁内只放「读-判断-写」的必要操作,库存预检、权限校验放锁外;远程 RPC、慢 SQL 绝不放进临界区,否则锁被长事务拖住,整个资源维度都被阻塞。

五、踩坑记录

  1. 锁超时设太短:手动实现时 TTL 设 10 秒,结果临界区里有慢 SQL 跑了 15 秒,锁提前过期,两个线程同时进入,数据错乱。解法:Redisson 看门狗,或按 P99 耗时评估 leaseTime 并加锁内耗时监控。
  2. 误删他人锁:释放时不比对 token,A 的锁过期后 B 加锁,A 回来 DEL 把 B 的锁删了。解法:Lua 脚本比对唯一标识,Redisson 已内置,手动实现必须抄对。
  3. 主从切换丢锁:master 宕机时锁还没来得及同步到 slave,新 master 上无锁,另一实例可同时加锁。解法:绝大多数业务接受单节点 + 看门狗即可;强一致场景 RedLock 实现复杂且社区争议大,不如用数据库唯一索引 / 乐观锁版本号做最终兜底。
  4. waitTime 设置不当:设为 0 时拿不到锁立刻抛异常,前端疯狂重试,Redis 连接被打满。解法:waitTime 给 200~500ms,拿不到锁走「快速失败 + 提示重试」或丢进队列削峰。
  5. key 无规范无兜底:锁 key 不带业务前缀、不设 TTL,导致 key 膨胀或死锁。解法:统一 lock:{业务}:{id} 命名规范,TTL 兜底,监控平台定期巡检残留锁。

六、最佳实践总结

  • 首选 Redisson,别重复造轮子:可重入、看门狗、公平锁、读写锁开箱即用;
  • 锁粒度按资源维度拆分,锁内代码最短化,锁外校验锁内写;
  • leaseTime 策略明确二选一:看门狗续期 或 固定超时,并监控锁等待时长与锁内耗时;
  • 拿不到锁必须有降级策略:快速失败、排队、限次重试,杜绝无限阻塞;
  • 锁只是性能手段,数据库唯一索引 / 版本号才是最终防线,双保险才能扛住极端并发;
  • 定期巡检:Redis 中残留锁 key、锁内耗时超过阈值的业务要重点重构。