很多做后端的同学最近应该都有同感:越是体量惊人的技术赛道,越容易在大庭广众之下翻车。你可能已经刷到过类似讨论——某个被资本寄予厚望的服务,在上线当天或活动高峰期突然卡死,页面转圈,接口超时,用户反馈铺满评论区,最终演变成一次公开的“社死”时刻。
这里不准备点名是具体哪一家,也不准备吃瓜复盘公关文案。作为技术人,更值得做的是把这类事故当做一个“共性技术案例”来拆解:为什么一个投入巨大、容量理应充足、测试理应充分的系统,会在关键时刻掉链子?如果这套系统是我们在维护,怎么避免?
本文会从这类事故背后的技术原因出发,带大家搭建一个模拟的高并发业务保护系统,覆盖限流、熔断、降级、异步削峰、幂等、可观测性,并给出故障复盘的流程和工程建议。无论你做后端、架构,还是要负责线上稳定性,都能从里面找到能直接用的东西。
1. 事件回顾:千亿赛道的“社死”是怎么发生的
1.1 事件的本质不是“卡了”,而是系统性失灵
“系统崩了”这四个字,用户说出来很轻松,但在技术视角里,它往往是一个多因素叠加的结果。
每次大型故障背后都不是“某个按钮坏了”,而是:
- 用户请求进不来,网关或负载均衡先扛不住;
- 进来的请求把数据库连接池耗尽;
- 服务与服务之间互相等待,线程池被打满;
- 日志疯狂刷屏,监控告警铺满群聊;
- 应急同学打开控制台,发现关键指标全部异常,一时分不清根因。
这种状态,比“某一个功能不可用”严重得多。它是整个系统在业务高峰期的集体性失灵,是架构设计、容量评估、故障演练、可观测性建设等多个环节共同欠下的债,一次性到期。
这类故障的特点是“平时不炸,一炸就大”。在低峰期,系统退化为半可用状态用户也能忍受;但一旦流量上来,所有隐藏问题同时暴露,系统就像一根被压断的梁,不是弯曲,而是折断。
1.2 为什么技术事故会升级为“社死”
技术事故本身并不罕见,罕见的是“在公开场景下被大量用户同时围观”。
当事故发生在线上发布会、年度大促、新功能首发这种节点时,用户端会同步出现“打不开、进不去、下单失败、页面白屏”等现象。信息传播速度极快,用户的第一反应可能不是“服务商技术能力不行”,而是“这家产品根本不可信”。
对于千亿级赛道中的玩家来说,一次成功的首发或大促,可能直接影响产品口碑、资本市场信心和用户留存。反过来,一次公开故障,也能让前期积累的信任短时间内打折。
所以,所谓“社死”,本质上不是面子问题,而是信任问题。技术人需要意识到:越是在高光时刻,系统的稳定性就越等于产品的生命线。这也是为什么,稳定性建设不能等到事故发生后,再靠“加班”和“人肉”去补。
1.3 本文能帮你掌握什么
这篇文章不是一篇“看热闹”的事件评论,而是一套可以从头落地的稳定性工程方案。
读完之后,你会掌握:
- 限流、熔断、降级、削峰这四大保护机制的落地思路;
- Redis + Lua 实现滑动窗口限流,以及 Nginx 网关限流配置;
- 一个模拟抢购系统从表设计到核心代码的完整实现;
- 用幂等表避免重复下单的关键设计;
- 故障复盘的标准流程与方法论;
- 平时可以做哪些容量评估、压测演练和可观测性建设。
如果你正负责一个高并发系统的设计与维护,这篇文章可以作为你的稳定性工具箱。如果你还在学习阶段,也能通过这份完整的案例,理解“为什么系统会崩”以及“怎么让系统不崩”。
2. 这类事故背后,到底是什么技术问题
先不急着写代码。我们需要把这类事故背后的技术原因拆开来看。通常,一个大型系统公开翻车,问题往往不是单一的,而是以下四类问题的组合。
2.1 容量评估失真:把“压测通过”当成了真相
很多团队在活动前会做压测,压测报告显示“单机 QPS 2000,集群 20 台,总容量 40000 QPS”,看似没问题。但生产环境的真实流量和压测流量差异很大:
- 压测数据往往是理想化请求,而真实请求参数千奇百怪;
- 压测通常只关注单个核心链路,但生产环境是完整调用链;
- 压测流量分布均匀,而用户行为存在明显的聚集效应。
举个例子:一个电商抢购活动,假设 100 万人同时点击,网关的真实峰值可能不是 100 万 QPS,而是“瞬间打进来的并发连接数”极大。如果按平均流量扩容,就会在起始秒被击穿。
容量评估的正确做法,不是为了测出“系统上限”,而是为了反推出“需要多少资源才能扛过峰值”。这里有一个简单公式可参考:
高峰 QPS ≈ 预估用户数 × 单用户平均请求数 ÷ 峰值持续秒数 实际预留容量 = 高峰 QPS × 缓冲系数缓冲系数一般建议在 1.5 到 2 之间,核心链路还可以更激进一些。当然,这只是一个粗略估算。真实系统要靠压测和监控数据不断校准,而不是发布会前拍脑袋定个数字。
2.2 链路缺少自我保护:依赖一抖,全链路崩
微服务架构中,一个请求往往要经过网关、鉴权服务、用户服务、库存服务、订单服务、支付服务等多个节点。
如果这些节点之间没有超时控制、没有熔断、没有隔离,那么只要有一个下游服务变慢,上游服务的线程池就会被慢慢占满。等线程池满了,后续请求直接排队,新的线程不断创建,最终导致 CPU 和内存耗尽,整条链路雪崩。
常见的连锁反应是:
数据库 CPU 升高 → 订单服务查询变慢 → 订单服务线程池打满 → 网关转发超时 → 用户重试 → 更多请求进入 → 数据库彻底不可用这种问题靠“临时加机器”往往没用。因为瓶颈在下游数据库或某个单点,而机器加了,请求只会更快地涌向下游。
所以,服务必须有自己的保护机制:超时时间要设置,线程池隔离要配置,熔断器要能自动切断对故障依赖的调用。这些内容会在第 4 章展开。
2.3 重试风暴:一次超时引发的雪崩
很多开发者习惯在调用失败后加一层重试,这本是提高成功率的常见手段。但如果没有限制重试次数,或者重试没有退避策略,它就会变成一场灾难。
假设一个接口原本的 QPS 是 5000,超时率略高。客户端 SDK 默认重试 3 次,那么实际打到服务端的请求就会变成 20000。如果服务端继续超时,客户端继续重试,最终整个系统都会被重试请求淹没。
更可怕的是重试风暴会跨服务传播。A 服务重试 B 服务,B 服务重试 C 服务,C 服务超时又触发 A 服务的新请求,形成循环放大。
正确的做法是:
- 重试次数限制在 1 到 2 次;
- 重试之间增加退避时间,例如指数退避;
- 对重试请求做全局识别和去重,避免重复扣款、重复下单;
- 关键写操作不要盲目重试,必须配合幂等设计。
这也是很多团队在活动后复盘时才发现的问题:真正的流量洪峰不是来自用户,而是来自系统自身的重试。
2.4 可观测性缺位:故障发生后还在盲人摸象
一个健康的系统,在故障发生时应该能回答三个问题:
- 当前有多少请求失败?
- 卡在哪个环节?
- 影响范围有多大?
如果系统的监控只有“CPU 使用率”和“内存使用率”,那么故障发生时,团队只能看到“服务器负载高”,却看不到具体是数据库慢、Redis 连接失败,还是某个接口被刷。
业界常用 RED 指标来做核心链路监控:
| 指标 | 含义 | 常见监控工具 |
|---|---|---|
| Rate | 请求速率 | Prometheus + Grafana |
| Errors | 错误数量/错误率 | 日志平台、APM |
| Duration | 请求耗时/延迟分布 | SkyWalking、Zipkin |
除了指标之外,链路追踪也很重要。一次完整的调用会经过多个服务,如果每个服务各自打日志,但没有 traceId 串联,排查问题时根本无法还原调用链。
可观测性建设不是“锦上添花”,而是事故处理时的“探照灯”。没有探照灯的团队,在故障中只能靠猜,而靠猜的应急,往往越救越乱。
3. 先搭一个可复现的模拟场景
前面的分析比较宏观,下面我们把问题落到一个具体的业务场景里。
3.1 模拟业务:一场“抢购”活动
假设我们运营一个电商平台,准备在周末晚上 8 点开放一批限量商品抢购。库存只有 1000 件,但预计参与人数会达到数十万。
业务要求:
- 不能超卖(最终数据库库存不能为负数);
- 同一个用户只能成功抢购一次;
- 高峰期不能把系统打挂;
- 用户下单后不需要立即拿到结果,可以异步排队。
这是一个非常经典的“高并发写 + 有限库存 + 防重复”场景,几乎能覆盖稳定性设计的大部分要点。
3.2 系统假设与技术栈
为了演示原理,我们做一个简化但完整的服务端原型:
| 组件 | 作用 |
|---|---|
| Nginx | 网关层限流 |
| Redis | 滑动窗口限流 + 库存预扣 |
| RocketMQ / RabbitMQ | 异步削峰,消息驱动下单 |
| MySQL | 活动库存、订单、幂等表 |
| Spring Boot | 对外接口与服务逻辑 |
这里不绑定具体版本,示例代码以 Spring Boot 2.7.x / 3.x 的常见写法为例。你使用时需要根据项目实际依赖树调整版本号,重点理解的是链路和思路。
3.3 架构总览
整个请求链路设计如下:
用户请求 │ ▼ Nginx 网关(限流:IP/用户维度) │ ▼ Spring Boot 接口层(业务限流、参数校验) │ ▼ Redis(滑动窗口限流 + 预扣库存) │ ▼ 消息队列(异步下单) │ ▼ 消费者服务(事务:幂等判断 + 库存扣减 + 订单入库) │ ▼ MySQL(最终数据落库)这个链路的核心思想是:让真正打到数据库的请求数量可控,而不是把所有流量直接穿透到 MySQL。
3.4 环境约定
实际操作时,建议在本地或测试环境准备:
- JDK 1.8+;
- Maven 3.6+;
- Redis 6.x;
- MySQL 5.7+ / 8.x;
- 一个消息队列(本地可用 RocketMQ 或 RabbitMQ 的 Docker 镜像)。
如果你是第一次搭建,不必追求完整分布式环境,先跑通“Redis 限流 + 预扣库存 + SQL 落库”的流程就足够了。
4. 核心保护机制:限流、熔断、降级、削峰
在实战之前,先单独把四个关键机制讲清楚,否则后面看代码会只知其然,不知其所以然。
4.1 网关层限流:挡住第一波流量
网关是流量的第一道门。在 Nginx 层做限流,能让大部分恶意请求或突发流量在进入业务服务之前就被丢弃。
Nginx 自带的limit_req_zone模块可以实现基于 IP 的固定窗口限流。
# 文件路径:nginx/conf/nginx.conf http { # 定义限流区域:按客户端 IP 限流,10m 约能保存 16 万个 IP 状态 limit_req_zone $binary_remote_addr zone=seckill_api:10m rate=10r/s; upstream backend_seckill { server 127.0.0.1:8080; } server { listen 80; location /api/seckill/ { # burst 表示允许瞬时超过 rate 的请求数,nodelay 表示超出的请求直接拒绝 limit_req zone=seckill_api burst=20 nodelay; proxy_pass http://backend_seckill; } } }配置说明:
rate=10r/s表示每个 IP 每秒最多通过 10 个请求;burst=20表示允许瞬间有 20 个请求排队等待,超过的立即返回 503;nodelay表示排队请求不进行延迟处理,能处理就立即处理,否则直接拒绝。
网关层限流的优点是“离用户最近、性能最好”,缺点是只能基于网络层信息(如 IP)进行判断。真正的业务维度的限流,还需要在应用层做。
4.2 Redis + Lua 实现滑动窗口限流
Nginx 的limit_req本质上是固定窗口限流。固定窗口有一个经典问题:窗口切换瞬间可能出现双倍流量。
假设限流规则是“每分钟 100 次”,固定窗口在 00:59 允许了 100 次请求,在 01:00 又允许了 100 次请求,那么实际上 1 秒内放行了两倍流量。滑动窗口通过精细的统计,能有效缓解这个问题。
这里给出一个基于 Redis ZSET 的滑动窗口限流脚本。脚本采用member = 时间戳 + 业务唯一ID的方式,确保同一时间点的不同请求不会因为 member 相同而被去重。
-- 文件路径:scripts/slide_window_rate_limit.lua -- KEYS[1] 限流 key,例如 seckill:rate:user_123 -- ARGV[1] 当前时间戳(毫秒) -- ARGV[2] 窗口大小(毫秒) -- ARGV[3] 窗口内最大请求数 -- ARGV[4] 业务唯一 ID,例如 traceId 或 UUID local key = KEYS[1] local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local maxRequests = tonumber(ARGV[3]) local requestId = ARGV[4] -- 1. 移除窗口外的旧数据 redis.call('ZREMRANGEBYSCORE', key, 0, now - window) -- 2. 统计当前窗口内请求数 local currentCount = redis.call('ZCARD', key) if currentCount >= maxRequests then return 0 end -- 3. 记录本次请求 redis.call('ZADD', key, now, now .. '_' .. requestId) -- 4. 设置过期时间,避免 key 堆积 redis.call('PEXPIRE', key, window) return 1这段脚本通过 ZSET 保存每次请求的时间戳,在每次请求进入时先清理过期记录,再判断当前数量是否超限。整个过程在 Redis 中原子执行,不会出现并发竞态问题。
调用 Lua 脚本的 Java 端可以使用 Spring Data Redis 的DefaultRedisScript。
4.3 服务层熔断:防止故障扩散
限流解决的是“流量太大”的问题,熔断解决的是“下游已经病了,别再继续打它”的问题。
熔断器的核心状态包括:
- 关闭(Closed):正常调用;
- 打开(Open):直接抛出异常或走降级逻辑,不再调用下游;
- 半开(Half-Open):允许少量试探请求,判断下游是否恢复。
主流实现有 Sentinel 和 Resilience4j。下面是一段 Resilience4j 的配置示例,表示对库存服务的调用如果错误率达到 50% 且最小调用次数达到 5 次,就打开熔断器,10 秒后再进入半开状态放行少量请求测试恢复情况。
# 文件路径:src/main/resources/application.yml resilience4j.circuitbreaker: instances: stockService: slidingWindowSize: 20 minimumNumberOfCalls: 5 failureRateThreshold: 50 waitDurationInOpenState: 10s permittedNumberOfCallsInHalfOpenState: 5 recordExceptions: - java.io.IOException - java.util.concurrent.TimeoutException在代码中可以通过注解或编程式 API 使用。核心思想是:当依赖不稳定时,不再盲目发起请求,而是快速失败,把压力挡在上游。
4.4 降级策略:拿不到最优,也要给可用
降级和熔断经常一起出现。熔断是“停止调用故障依赖”,降级是“连不上或熔断后,给用户一个备用响应”。
常见的降级方式有:
- 返回缓存数据(如商品详情页缓存);
- 返回默认值(如推荐列表为空时返回热门默认列表);
- 关闭非核心功能(如暂停评论、暂停搜索联想);
- 异步化处理(先返回“排队中”,再异步执行)。
在设计降级策略时,要提前定义好“哪些是核心链路,哪些是非核心链路”。比如支付场景中,“查询订单状态”是核心,“发送营销短信”是非核心。当系统压力大时,非核心功能可以优先降级,把资源留给核心交易。
4.5 异步削峰:把同步下单改成消息驱动
高并发抢购最忌“每个请求都同步写数据库”。100 万请求如果同时打到 MySQL,再好的机器也会被锁等待拖垮。
解决办法是削峰填谷。用户请求进入后,先在 Redis 中完成库存预扣,再把“创建订单”这件事发送到消息队列,由消费者异步处理。
这样,接口对用户的响应时间非常短,流量峰值被消息队列缓冲,数据库只接收稳定的、可控的写入速率。
需要特别注意:引入消息队列后,必须处理消息丢失和重复消费。因此,生产端要可靠投递,消费端要做幂等。
5. 完整实战:给“抢购系统”加上保护罩
下面我们完整实现一个简化版抢购系统中的核心部分。
5.1 数据库表与幂等设计
先看库存表。为了演示,我们使用活动表记录商品库存和已售数量。
-- 文件路径:sql/init.sql CREATE TABLE `seckill_activity` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '活动ID', `product_id` BIGINT NOT NULL COMMENT '商品ID', `total_stock` INT NOT NULL COMMENT '总库存', `sold_stock` INT NOT NULL DEFAULT 0 COMMENT '已售库存', `start_time` DATETIME NOT NULL COMMENT '开始时间', `end_time` DATETIME NOT NULL COMMENT '结束时间', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`), KEY `idx_product_time` (`product_id`, `start_time`, `end_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动表';订单表:
CREATE TABLE `order_info` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` VARCHAR(64) NOT NULL COMMENT '订单号', `user_id` BIGINT NOT NULL COMMENT '用户ID', `activity_id` BIGINT NOT NULL COMMENT '活动ID', `product_id` BIGINT NOT NULL COMMENT '商品ID', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0创建中 1成功 2失败', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_activity` (`user_id`, `activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';幂等表的设计很关键。它的作用是在消息重复消费时,保证同一用户同一活动不会生成两份订单。
CREATE TABLE `idempotent_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `biz_key` VARCHAR(128) NOT NULL COMMENT '业务幂等键', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz_key` (`biz_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幂等记录表';biz_key可以设计为userId:activityId,通过唯一索引保证重复插入失败,进而在数据库层面拦截重复下单。
-- 数据库层面再次兜底幂等 INSERT INTO idempotent_record (biz_key) VALUES ('10001:1001');如果再次执行同一条 SQL,会触发Duplicate entry异常。这也是为什么消费逻辑里需要捕获这个异常并“静默成功”。
5.2 Redis Lua 限流脚本
我们将第 4.2 节的限流脚本保存到项目 resources 目录,并在 Java 中加载执行。
// 文件路径:src/main/java/com/example/seckill/ratelimit/RateLimiter.java @Component public class RateLimiter { @Resource private StringRedisTemplate stringRedisTemplate; private static final String RATE_LIMIT_SCRIPT = "redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2])) " + "local count = redis.call('ZCARD', KEYS[1]) " + "if count >= tonumber(ARGV[3]) then return 0 end " + "redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1] .. '_' .. ARGV[4]) " + "redis.call('PEXPIRE', KEYS[1], ARGV[2]) " + "return 1"; private static final DefaultRedisScript<Long> RATE_LIMIT_REDIS_SCRIPT; static { RATE_LIMIT_REDIS_SCRIPT = new DefaultRedisScript<>(); RATE_LIMIT_REDIS_SCRIPT.setScriptText(RATE_LIMIT_SCRIPT); RATE_LIMIT_REDIS_SCRIPT.setResultType(Long.class); } /** * 尝试通过限流 * * @param key 限流 key * @param windowMillis 窗口大小(毫秒) * @param maxRequests 窗口内最大请求数 * @param requestId 本次请求唯一标识 * @return true 通过,false 被限流 */ public boolean tryAcquire(String key, long windowMillis, long maxRequests, String requestId) { long now = System.currentTimeMillis(); Long result = stringRedisTemplate.execute( RATE_LIMIT_REDIS_SCRIPT, Collections.singletonList(key), String.valueOf(now), String.valueOf(windowMillis), String.valueOf(maxRequests), requestId ); return result != null && result == 1L; } }注意,StringRedisTemplate默认使用 String 序列化器,适合这种只存字符串的场景。如果使用RedisTemplate<Object, Object>,需要指定 key 和 value 的序列化器,否则会出现乱码 key。
5.3 Java 侧核心代码
我们先定义一个下单请求对象和返回结果包装类。
// 文件路径:src/main/java/com/example/seckill/controller/SeckillController.java @RestController @RequestMapping("/api/seckill") public class SeckillController { @Resource private SeckillService seckillService; @PostMapping("/order") public Result<String> createOrder(@RequestBody SeckillOrderRequest request) { return seckillService.createOrder(request.getUserId(), request.getActivityId()); } }请求对象:
// 文件路径:src/main/java/com/example/seckill/dto/SeckillOrderRequest.java public class SeckillOrderRequest { private Long userId; private Long activityId; public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } public Long getActivityId() { return activityId; } public void setActivityId(Long activityId) { this.activityId = activityId; } }Result 包装类:
// 文件路径:src/main/java/com/example/seckill/common/Result.java public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 0; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 500; result.message = message; return result; } public int getCode() { return code; } public String getMessage() { return message; } public T getData() { return data; } }核心服务实现。这里的重点是流程拼接:限流 → 预扣库存 → 判断幂等 → 发送消息。
// 文件路径:src/main/java/com/example/seckill/service/SeckillServiceImpl.java @Service public class SeckillServiceImpl implements SeckillService { @Resource private RateLimiter rateLimiter; @Resource private StockService stockService; @Resource private IdempotentService idempotentService; @Resource private OrderProducer orderProducer; private static final long WINDOW_MILLIS = 60 * 1000L; private static final long MAX_REQUESTS = 5L; @Override public Result<String> createOrder(Long userId, Long activityId) { String requestId = UUID.randomUUID().toString().replace("-", ""); // 1. 用户维度限流,每个用户每分钟最多 5 次 boolean allowed = rateLimiter.tryAcquire( "seckill:rate:user:" + userId, WINDOW_MILLIS, MAX_REQUESTS, requestId ); if (!allowed) { return Result.error("操作过于频繁,请稍后再试"); } // 2. Redis 预扣库存 boolean decrSuccess = stockService.tryDeductStock( "seckill:stock:activity:" + activityId, 1 ); if (!decrSuccess) { return Result.error("很遗憾,商品已抢完"); } // 3. 幂等判断(这里做一个前置判断,最终以数据库为准) String bizKey = userId + ":" + activityId; if (idempotentService.exists(bizKey)) { return Result.error("您已参与该活动,请勿重复提交"); } // 4. 发送异步下单消息 OrderMessage message = new OrderMessage(userId, activityId, bizKey); orderProducer.send(message); return Result.success("排队中,请稍后查询订单结果"); } }这里需要强调的是:Redis 预扣库存只是“内存级”的判断,它减少了数据库的压力,但并不是最终数据。最终库存扣减仍然要在数据库事务中完成,并配合乐观锁防止超卖。
5.4 消费者处理订单
消费者从消息队列中取出消息,在数据库事务里完成真正的扣减库存与创建订单。
// 文件路径:src/main/java/com/example/seckill/mq/OrderConsumer.java @Component public class OrderConsumer { @Resource private SeckillOrderService seckillOrderService; @RabbitListener(queues = "seckill.order.queue") public void onMessage(OrderMessage message) { seckillOrderService.createOrderInTx(message); } }事务处理逻辑:
// 文件路径:src/main/java/com/example/seckill/service/SeckillOrderServiceImpl.java @Service public class SeckillOrderServiceImpl implements SeckillOrderService { @Resource private IdempotentRecordMapper idempotentRecordMapper; @Resource private ActivityMapper activityMapper; @Resource private OrderMapper orderMapper; @Override @Transactional(rollbackFor = Exception.class) public void createOrderInTx(OrderMessage message) { // 1. 插入幂等记录,重复插入会触发唯一键冲突 try { idempotentRecordMapper.insert(message.getBizKey()); } catch (DuplicateKeyException e) { // 已处理过,直接返回,避免重复下单 return; } // 2. 扣减库存,通过条件更新防止超卖 int rows = activityMapper.deductStock(message.getActivityId()); if (rows == 0) { throw new RuntimeException("库存扣减失败,活动可能已售罄"); } // 3. 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(message.getUserId()); order.setActivityId(message.getActivityId()); order.setStatus(1); orderMapper.insert(order); } }库存扣减 SQL 使用条件更新:
-- 文件路径:src/main/resources/mapper/ActivityMapper.xml UPDATE seckill_activity SET sold_stock = sold_stock + 1 WHERE id = #{activityId} AND sold_stock < total_stock这段 SQL 是防超卖的关键:只有“已售库存小于总库存”时才会更新成功。如果更新行数为 0,说明库存不足,直接在事务中抛出异常回滚。
事务中插入幂等记录和订单的关系要理清楚:幂等记录先插入成功,订单插入失败时会触发事务回滚,幂等记录也会回滚,这样后续消息重试时还能重新处理。如果希望即使订单失败也不允许重试,可以调整业务语义,但通常重试是可以接受的。
5.5 压测验证与预期结果
如果本地环境完整搭建,可以启动服务后用压测工具如 JMeter、wrk 或 ab 模拟 1000 并发请求。
简单压测可以这样发起:
ab -n 10000 -c 200 -p order.json -T application/json \ http://127.0.0.1:8080/api/seckill/order其中order.json内容:
{ "userId": 10001, "activityId": 1001 }预期结果大致如下:
| 请求结果 | 说明 |
|---|---|
| 返回“排队中” | 请求通过限流,进入异步队列 |
| 返回“操作过于频繁” | 同一用户超过限流阈值 |
| 返回“商品已抢完” | Redis 预扣库存失败 |
| 返回“您已参与该活动” | 幂等拦截重复请求 |
最终数据库订单表里的订单数量应该等于实际能下单的数量,不会超过总库存,也不会出现同一用户两个订单。
5.6 结果说明与优化空间
这个原型演示了“限流 + 预扣 + 异步下单 + 幂等”这条主链路。能做到:
- 用户请求快速返回,不会长时间占用线程;
- 真正打到数据库的写入请求被消息队列削峰;
- 库存扣减通过条件更新避免超卖;
- 重复消息通过幂等表被拦截。
但离生产级系统还有距离,优化方向包括:
- 在消息发送前增加本地消息表,保证消息零丢失;
- 使用 Redis 分布式锁防止极端情况下的超卖;
- 增加订单查询接口,让用户感知异步处理进度;
- 将静态商品信息缓存到 Redis,降低数据库读压力;
- 引入配置中心,让限流阈值可以动态调整,而不是改代码发版。
6. 事故发生后如何复盘:从技术到管理
系统做得再好,也有出问题的可能。真正拉开团队差距的,往往是故障之后的处理方式。
6.1 复盘不是追责,而是还原真相
很多公司一发生故障,第一反应是找人背锅。这种文化会导致参与者隐瞒细节、销毁证据、互相推诿,最后复盘变成一场“问责会”,没有任何技术收获。
正确的复盘文化应该是:把故障当成一次系统设计缺陷的展示,追问“为什么会走到这一步”,而不是“这是谁的错”。
在复盘前,需要准备的数据包括:
- 完整的时间线;
- 监控指标截图;
- 变更记录;
- 发布记录;
- 相关日志和链路追踪数据;
- 参与人员的操作记录。
6.2 时间线还原
复盘的第一步,是还原事件发生的时间线。下面是一张标准的时间线模板:
| 时间 | 事件 | 数据来源 |
|---|---|---|
| 20:00:00 | 活动开始,流量上升 | 网关日志 |
| 20:00:31 | 商品详情接口 P99 延迟超过 3s | 监控系统 |
| 20:01:10 | 订单服务线程池打满 | 线程监控 |
| 20:01:40 | 数据库慢查询数量激增 | 数据库监控 |
| 20:02:00 | 支付超时比例达到 30% | APM |
| 20:05:00 | 紧急扩容 20 台机器 | 运维操作记录 |
| 20:15:00 | 服务逐步恢复 | 监控系统 |
| 20:30:00 | 活动暂停,数据校验 | 业务系统 |
时间线还原越详细,后续根因分析就越准确。建议在故障期间安排一个人专门记录时间,而不是等事后靠大家回忆。
6.3 五个为什么根因分析
时间线还原之后,使用“五个为什么”方法逐层追问,找到根本原因。
举例:
问题:为什么订单服务线程池打满? 为什么1:因为下游库存服务响应变慢。 为什么2:因为库存服务依赖的数据库出现大量锁等待。 为什么3:因为秒杀活动多个请求同时更新同一行库存记录。 为什么4:因为库存行是热点行,没有做锁粒度优化。 为什么5:因为系统没有针对热点行场景做削峰和异步化设计。最终发现,根因不是“运维扩容晚了”,也不是“测试没测出来”,而是“架构上没有针对热点并发场景设计保护机制”。这样的根因才具有改进意义。
6.4 改进措施的闭环管理
复盘的最后一步是输出改进措施。关键一点是:每个措施必须有负责人和截止时间。
一个好用的改进事项模板:
| 改进项 | 负责人 | 截止时间 | 优先级 | 验证方式 |
|---|---|---|---|---|
| 优化库存扣减热点行 | 张三 | 下周五 | P0 | 压测验证 |
| 增加 Redis 预扣库存 | 李四 | 下周五 | P0 | 压测验证 |
| 补充链路监控大盘 | 王五 | 两周内 | P1 | 故障演练 |
| 修改重试策略 | 赵六 | 下周三 | P1 | Code Review |
改进措施如果没有验证环节,很容易在两周后被遗忘。推荐每季度做一次故障复盘抽查,确认之前的改进项是否真正落地。
7. 常见问题与排查思路
这里整理一些在高并发保护系统落地时常见的问题。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 库存扣了,但订单没有生成 | MQ 消息丢失或消费者异常 | 引入本地消息表或事务消息,增加消费失败重试 |
| 用户重复下单 | 幂等逻辑没有覆盖所有入口 | 数据库唯一索引兜底,统一走幂等表 |
| 限流误伤正常用户 | 限流阈值设置过小 | 根据压测数据调整阈值,提供配置中心动态调参 |
| Redis 中出现大量无用 key | 限流 key 没有设置过期时间 | ZSET 方案必须配置 PEXPIRE,并设置合理的窗口时间 |
| 接口超时但数据库负载不高 | 线程池等待或下游第三方接口慢 | 检查线程池指标,调用链分析定位慢节点 |
| 熔断后恢复慢 | 半开状态下试探请求过少 | 调整 permittedNumberOfCallsInHalfOpenState 参数 |
| 消息重复消费 | 消费端没有幂等处理 | 幂等表或订单号唯一索引兜底 |
遇到异常时,可按以下排查顺序处理:
- 先看告警群和监控大盘,确定影响范围;
- 查链路追踪,确认故障节点;
- 查日志中的 traceId,从入口到出口完整看一遍;
- 看数据库慢查询、连接池使用率、锁等待情况;
- 看 Redis 命中率、慢命令、连接数;
- 确认近期是否有发布或配置变更;
- 根据根因执行回滚、扩容或降级操作。
注意:任何线上变更都要先评估影响面,能回滚优先回滚,不要贸然“临时改代码”或“重启集群”。
8. 工程最佳实践与长期建设
稳定性不是靠一次活动保卫战打出来的,而是靠日常建设积累的。下面这些实践,值得落到日常工作中。
8.1 容量评估不能只在发布前做
容量评估应该是一个持续过程。
- 平时记录核心接口的日常峰值;
- 根据业务计划预测活动期的增长系数;
- 每次压测结果都归档,形成容量基线;
- 通过弹性伸缩策略,让系统在流量上涨时自动扩容。
不要等到发布会前一天才做压测。压测数据要能回答:“以当前配置,扛到哪个量级会开始恶化”。
8.2 压测、演练、混沌工程
很多系统“看起来正常”,只是没有遭遇过异常。
建议定期做:
- 全链路压测:模拟完整业务链路的高峰流量;
- 节点演练:随机杀掉一个实例,看系统是否自动转移流量;
- 依赖演练:模拟 Redis、MySQL、第三方接口故障,看系统是否能够降级运行;
- 容量演练:把流量逐步加到容量上限,观察系统在哪一步崩溃。
混沌工程的目标不是“制造故障”,而是验证系统在面对不确定性时的韧性。
8.3 变更管理
大量故障的起因其实是一次不起眼的发版。
变更管理的核心原则:
- 先灰度,再全量;
- 发布前有回滚方案;
- 配置变更要做 diff 审查;
- 禁止高峰期直接改核心链路配置;
- 每一次变更都要有可观测的指标变化验证。
如果团队规模较小,也至少要做到“发布前备份、发布后观察、出问题能秒回滚”。
8.4 可观测性建设
一个成熟的系统,应该具备以下能力:
- 日志:所有服务输出结构化日志,统一格式,包含 traceId;
- 指标:核心链路建设 RED 指标(请求速率、错误率、耗时);
- 链路追踪:引入 SkyWalking 或 Zipkin,串联跨服务调用;
- 告警:告警规则要有分级,避免告警轰炸导致重要告警被忽略;
- 大盘:建立业务大盘、系统大盘、依赖大盘,故障时能一屏定位。
很多团队的问题不是没有监控,而是监控太多、太碎。可观测性建设的目标是“在 5 分钟内完成一屏定位”,而不是“跳转 8 个系统看 10 张报表”。
8.5 成本与稳定性的平衡
稳定性建设不等于疯狂堆机器。更合理的方式是:
- 核心链路高可用,非核心链路节省资源;
- 通过削峰填谷减少峰值资源需求;
- 使用弹性伸缩,让容量跟着流量走;
- 定期分析容量利用率,回收闲置资源。
稳定性与成本的平衡点,需要结合业务特点和预算来确定。但有一个原则是通用的:先用架构手段解决问题,再用资源手段兜底。架构上防不住,才考虑用机器硬扛。
9. 总结:系统的体面是设计出来的
回到“千亿赛道”的“社死”时刻。你会发现,那些在大庭广众之下崩溃的系统,往往不是输在代码写得不好,而是输在缺少对极端场景的敬畏和预案。
限流、熔断、降级、削峰、幂等、可观测性、故障演练——这些技术名词背后,不是“炫技”,而是对系统边界的一种清醒认知。只有承认系统会失败,我们才会主动给它套上保护壳。
本文用一个简化抢购系统展示了从流量入口到数据库落库的完整保护链路。你可以在此基础上继续扩展,比如接入配置中心实现动态限流阈值、增加分钟级订单统计、引入全链路压测平台等等。
系统的体面和人的体面一样,不是靠运气,而是靠一次次故障后的复盘、一次次压测中的加固、一次次演练里的修补慢慢换来的。希望这篇文章能帮你在下一次“关键时刻”来临时,多一份从容。