这期是整个项目的核心 需要反复咀嚼消化 后面还需提炼有价值的问题进行分析
一、从一人一单说起
先回顾一个问题。
单机环境下,用synchronized按 userId 加锁,能保证同一用户同一时间只有一个线程执行下单逻辑。代码大概长这样:
synchronized(userId.toString().intern()){// 查询订单、扣减库存、创建订单}但项目一旦集群部署——三台机器、三个 JVM、三个锁监视器——这套就废了。请求被负载均衡到不同 Tomcat,每个 JVM 各锁各的,A 机器加的锁 B 机器根本看不见。同一个用户的两个请求同时落到不同机器,一人一单直接破功。
解决方案:让多个 JVM 共用一把锁。
这把锁不能放在某个 JVM 的内存里,必须放在所有节点都能访问的公共存储中心。Redis,凭借单线程内存操作和高性能,成了天然选择。
二、分布式锁:从 SETNX 到 Lua
2.1 初代方案:SETNX + 过期时间
加锁用SETNX(Set if Not eXists),谁先创建 Key 谁拿到锁。为防止线程宕机导致锁永不释放,加个过期时间。
// SETNX + EXPIRE 原子操作stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name,"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 分布式锁的完整演进
| 版本 | 方案 | 问题 |
|---|---|---|
| V1 | SETNX | 死锁(没设过期时间) |
| V2 | SETNX + EXPIRE | 非原子操作,可能漏设过期时间 |
| V3 | SET NX PX | 原子加锁+过期,但锁可能被误删 |
| V4 | UUID + 手动判断删除 | 判断和删除非原子 |
| V5 | Lua 脚本 | 原子性解决 |
但自己手写分布式锁要处理的边角情况远不止这些。
三、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 提供RedLock(multiLock)解决:同时向多个 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 到 Stream
5.1 BlockingQueue 的局限
黑马点评第一版异步方案用的是 JVM 内存里的BlockingQueue。
两个致命问题:
- 内存限制:高并发下队列积压,可能撑爆 JVM 内存。
- 数据丢失:服务宕机,队列里未处理的消息全丢。
5.2 Redis Stream:更轻量的 MQ
Redis 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 → 轻量级 MQ,ACK + 持久化 └── RabbitMQ → 生产级方案,削峰解耦从本地锁到分布式锁,从手写 Redis 锁到 Redisson,从同步下单到异步秒杀,从 BlockingQueue 到 Stream 再到 RabbitMQ——每一步都在解决上一步的痛点。架构没有终点,只有不断演进。
面试核心考点:分布式锁的实现与缺陷、Redisson 的可重入与看门狗原理、Lua 脚本保证原子性、秒杀优化的异步思路、Stream 消费者组与 ACK 机制。