news 2026/8/29 5:01:20

黑马点评(四):分布式锁 + 秒杀优化 + MQ

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
黑马点评(四):分布式锁 + 秒杀优化 + MQ

这期是整个项目的核心 需要反复咀嚼消化 后面还需提炼有价值的问题进行分析

一、从一人一单说起

先回顾一个问题。

单机环境下,用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])elsereturn0end

2.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 提供RedLock(multiLock)解决:同时向多个 Redis 节点加锁,超过半数成功才算拿到锁。

3.5 原生 SETNX vs Redisson

特性SETNX 方案Redisson
可重入✅ Hash + 计数
重试机制✅ Pub/Sub 唤醒
自动续期✅ 看门狗
主从一致✅ RedLock

四、秒杀优化:把压力从 DB 搬到 Redis

分布式锁解决了并发安全问题,但性能呢?

回顾下单流程:

  1. 查询优惠券
  2. 判断库存是否充足
  3. 查询订单
  4. 校验一人一单
  5. 扣减库存
  6. 创建订单

每一步都在操作数据库,串行执行。高并发下数据库就是瓶颈。

优化思路:耗时短的逻辑判断挪到 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

两个致命问题:

  1. 内存限制:高并发下队列积压,可能撑爆 JVM 内存。
  2. 数据丢失:服务宕机,队列里未处理的消息全丢。

5.2 Redis Stream:更轻量的 MQ

Redis 5.0 引入Stream,一种轻量级消息队列。

消费者组模式

  • 组内多个消费者共同消费消息,一条消息只被一个消费者处理
  • 支持ACK 确认机制,保证消息至少被消费一次
  • 消息可持久化、可回溯

改造后的流程

请求线程 Redis Stream 后台线程 │ │ │ ▼ │ │ Lua 校验 + XADD ──────────────►│ │ │ │ │ ▼ │ │ 返回成功 │ │ │ │ │ XREADGROUP 阻塞读取 │ │◄───────────────────────────│ │ ▼ │ 落库 + XACK

Lua 脚本在校验通过后直接用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 机制。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/29 5:00:42

动态库与静态库的区别

1. 引言在 C/C 项目编译和链接的过程中&#xff0c;库文件分为静态库和动态库两大类。理解两者的区别&#xff0c;有助于在工程实践中做出合理选择&#xff0c;兼顾编译速度、运行效率和部署成本。2. 静态库静态库在链接阶段被完整复制到可执行文件中。链接完成后&#xff0c;可…

作者头像 李华
网站建设 2026/8/29 4:58:59

线性规划建模与LINGO求解实战:从原理到应用

1. 项目概述&#xff1a;从实际问题到数学模型的桥梁线性规划&#xff0c;这个名字听起来可能有点学术&#xff0c;但它的身影其实遍布我们生活的方方面面。小到家里怎么安排一周的买菜预算&#xff0c;让钱花得最值&#xff1b;大到一家工厂如何调配生产线&#xff0c;让利润最…

作者头像 李华
网站建设 2026/8/29 4:58:49

四年服务端社招面经:从复习主线到系统设计的实战拆解

写这篇东西的时候&#xff0c;我刚把手头这批服务端社招的Offer流程全部走完。前后拉扯了差不多两个多月&#xff0c;投了十几家&#xff0c;技术面加起来快三十轮&#xff0c;最后拿到了几个还算满意的结果。我自己的背景是四年服务端开发&#xff0c;前两年在一家创业公司写业…

作者头像 李华
网站建设 2026/8/29 4:58:30

整数规划:从线性规划到离散优化的建模与求解实战

1. 从线性到整数&#xff1a;为什么整数规划是另一回事刚接触运筹优化时&#xff0c;很多人会觉得整数规划&#xff08;Integer Programming, IP&#xff09;不过是线性规划&#xff08;Linear Programming, LP&#xff09;的一个“小变种”——无非是在线性规划的基础上&#…

作者头像 李华