:分布式锁 + 秒杀优化 + MQ)
这期是整个项目的核心 需要反复咀嚼消化 后面还需提炼有价值的问题进行分析一、从一人一单说起先回顾一个问题。单机环境下用synchronized按 userId 加锁能保证同一用户同一时间只有一个线程执行下单逻辑。代码大概长这样synchronized(userId.toString().intern()){// 查询订单、扣减库存、创建订单}但项目一旦集群部署——三台机器、三个 JVM、三个锁监视器——这套就废了。请求被负载均衡到不同 Tomcat每个 JVM 各锁各的A 机器加的锁 B 机器根本看不见。同一个用户的两个请求同时落到不同机器一人一单直接破功。解决方案让多个 JVM 共用一把锁。这把锁不能放在某个 JVM 的内存里必须放在所有节点都能访问的公共存储中心。Redis凭借单线程内存操作和高性能成了天然选择。二、分布式锁从 SETNX 到 Lua2.1 初代方案SETNX 过期时间加锁用SETNXSet if Not eXists谁先创建 Key 谁拿到锁。为防止线程宕机导致锁永不释放加个过期时间。// SETNX EXPIRE 原子操作stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIXname,1,timeoutSec,TimeUnit.SECONDS);看起来简单全是坑。2.2 坑一锁误删线程 A 拿到锁业务执行时间超过锁的过期时间锁自动释放。线程 B 拿到锁。此时 A 执行完毕调用DEL删锁——把 B 的锁删了。解决存锁时存入唯一标识UUID 线程 ID释放前先判断是不是自己的锁。2.3 坑二原子性但“判断标识”和“删除锁”是两个独立 Redis 命令。判断通过后、删除执行前锁恰好过期被别的线程抢走——你删的依然是别人的锁。解决Lua 脚本。多条 Redis 命令一次执行确保原子性。-- 解锁脚本判断标识一致才删除ifredis.call(get,KEYS[1])ARGV[1]thenreturnredis.call(del,KEYS[1])elsereturn0end2.4 分布式锁的完整演进版本方案问题V1SETNX死锁没设过期时间V2SETNX EXPIRE非原子操作可能漏设过期时间V3SET NX PX原子加锁过期但锁可能被误删V4UUID 手动判断删除判断和删除非原子V5Lua 脚本原子性解决但自己手写分布式锁要处理的边角情况远不止这些。三、Redisson生产级分布式锁自己实现一个健壮的分布式锁要考虑可重入性、锁续期、等待重试、主从一致性。成熟框架 Redisson 全给包了。3.1 可重入锁为什么需要可重入防止自己把自己卡死。场景方法 A 加了锁里面调用了方法 B方法 B 也需要同一把锁。如果锁不可重入方法 B 发现锁被占着其实占着的是自己就会阻塞等待——永远等不到自己释放死锁。Redisson 的实现用 RedisHash结构存储。Key 是锁名Field 是“客户端 ID 线程 ID”Value 是重入次数。同一个线程再次加锁次数 1释放一次次数 -1减到 0 才真正删除。-- Redisson 加锁 Lua简化if(redis.call(exists,KEYS[1])0)thenredis.call(hset,KEYS[1],ARGV[2],1)redis.call(pexpire,KEYS[1],ARGV[1])returnnilendif(redis.call(hexists,KEYS[1],ARGV[2])1)thenredis.call(hincrby,KEYS[1],ARGV[2],1)redis.call(pexpire,KEYS[1],ARGV[1])returnnilendreturnredis.call(pttl,KEYS[1])3.2 锁重试机制原生 SETNX 抢不到锁立刻失败。Redisson 基于 Redis 的Pub/Sub实现等待唤醒。获取锁失败的线程订阅一个频道锁释放时发布消息唤醒等待线程重新竞争。3.3 看门狗Watchdog自动续期这才是 Redisson 的精华。不手动指定leaseTime时Redisson 默认 30 秒超时。加锁成功后启动一个后台守护线程每隔 10 秒超时时间的 1/3检查业务是否还在执行。还在执行就自动把锁续到 30 秒。业务执行完显式释放锁看门狗任务取消。只要业务线程还活着锁就不会因为超时被意外释放。3.4 主从一致性问题Redis 主从架构下锁写入 Master 后异步复制到 Slave。Master 宕机Slave 还没同步到锁数据——新 Master 没有锁其他线程趁虚而入。Redisson 提供RedLockmultiLock解决同时向多个 Redis 节点加锁超过半数成功才算拿到锁。3.5 原生 SETNX vs Redisson特性SETNX 方案Redisson可重入❌✅ Hash 计数重试机制❌✅ Pub/Sub 唤醒自动续期❌✅ 看门狗主从一致❌✅ RedLock四、秒杀优化把压力从 DB 搬到 Redis分布式锁解决了并发安全问题但性能呢回顾下单流程查询优惠券判断库存是否充足查询订单校验一人一单扣减库存创建订单每一步都在操作数据库串行执行。高并发下数据库就是瓶颈。优化思路耗时短的逻辑判断挪到 Redis快速拦截无效请求。校验通过后直接告诉用户“下单成功”后台异步慢慢写库。4.1 Lua 脚本做资格判断新增秒杀券时把库存信息同步到 Redis。用户请求进来执行一个 Lua 脚本完成三件事-- 1. 判断库存是否充足if(tonumber(redis.call(get,stockKey))0)thenreturn1-- 库存不足end-- 2. 判断用户是否已下单一人一单if(redis.call(sismember,orderKey,userId)1)thenreturn2-- 重复下单end-- 3. 扣库存 记录用户redis.call(incrby,stockKey,-1)redis.call(sadd,orderKey,userId)return0-- 成功三个 Redis 操作合并成一个原子流程彻底避免高并发下的超卖和重复下单。4.2 异步下单Lua 返回 0 表示资格校验通过把订单信息扔进队列直接给用户返回“下单成功”。后台线程从队列里取消息慢慢写数据库。请求线程 后台线程 │ │ ▼ │ Lua 校验Redis │ │ │ ▼ │ 写入队列 ──────────────────► │ │ ▼ ▼ 落库DB 返回成功效果响应时间从秒级降到毫秒级吞吐量提升近十倍。五、消息队列从 BlockingQueue 到 Stream5.1 BlockingQueue 的局限黑马点评第一版异步方案用的是 JVM 内存里的BlockingQueue。两个致命问题内存限制高并发下队列积压可能撑爆 JVM 内存。数据丢失服务宕机队列里未处理的消息全丢。5.2 Redis Stream更轻量的 MQRedis 5.0 引入Stream一种轻量级消息队列。消费者组模式组内多个消费者共同消费消息一条消息只被一个消费者处理支持ACK 确认机制保证消息至少被消费一次消息可持久化、可回溯改造后的流程请求线程 Redis Stream 后台线程 │ │ │ ▼ │ │ Lua 校验 XADD ──────────────►│ │ │ │ │ ▼ │ │ 返回成功 │ │ │ │ │ XREADGROUP 阻塞读取 │ │◄───────────────────────────│ │ ▼ │ 落库 XACKLua 脚本在校验通过后直接用XADD把订单消息写入 Stream。后台线程以消费者组身份用XREADGROUP阻塞读取消息落库成功后XACK确认。处理异常时消息留在pending-list可重新处理。5.3 RabbitMQ更专业的选型Redis Stream 解决了 BlockingQueue 的问题但毕竟是 Redis 的“副业”。生产环境更常见的方案是引入专业 MQ。黑马点评也可以用RabbitMQ改造持久化消息落盘服务重启不丢削峰填谷流量洪峰下平滑处理解耦订单服务和库存服务彻底分离5.4 消息队列方案对比方案优势劣势BlockingQueue零依赖实现简单内存受限数据易丢Redis Stream轻量复用 Redis功能不如专业 MQ 完善RabbitMQ成熟可靠功能完备引入额外组件运维成本六、总结一条完整的演进链路单体应用 │ ├── synchronized 本地锁 → 一人一单 │ ▼ 集群部署 │ ├── SETNX 分布式锁 → 解决了跨 JVM 互斥 ├── UUID Lua → 解决了锁误删和原子性 ├── Redisson → 解决了可重入、重试、续期、主从一致 │ ▼ 秒杀优化 │ ├── Lua 脚本前置校验Redis→ 数据库压力大减 ├── 异步下单 → 响应时间从秒级到毫秒级 │ ▼ 消息队列 │ ├── BlockingQueue → 有内存和持久化风险 ├── Redis Stream → 轻量级 MQACK 持久化 └── RabbitMQ → 生产级方案削峰解耦从本地锁到分布式锁从手写 Redis 锁到 Redisson从同步下单到异步秒杀从 BlockingQueue 到 Stream 再到 RabbitMQ——每一步都在解决上一步的痛点。架构没有终点只有不断演进。面试核心考点分布式锁的实现与缺陷、Redisson 的可重入与看门狗原理、Lua 脚本保证原子性、秒杀优化的异步思路、Stream 消费者组与 ACK 机制。