尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

高并发系统稳定性实战:限流、熔断、降级与异步削峰全解析

高并发系统稳定性实战:限流、熔断、降级与异步削峰全解析 很多做后端的同学最近应该都有同感越是体量惊人的技术赛道越容易在大庭广众之下翻车。你可能已经刷到过类似讨论——某个被资本寄予厚望的服务在上线当天或活动高峰期突然卡死页面转圈接口超时用户反馈铺满评论区最终演变成一次公开的“社死”时刻。这里不准备点名是具体哪一家也不准备吃瓜复盘公关文案。作为技术人更值得做的是把这类事故当做一个“共性技术案例”来拆解为什么一个投入巨大、容量理应充足、测试理应充分的系统会在关键时刻掉链子如果这套系统是我们在维护怎么避免本文会从这类事故背后的技术原因出发带大家搭建一个模拟的高并发业务保护系统覆盖限流、熔断、降级、异步削峰、幂等、可观测性并给出故障复盘的流程和工程建议。无论你做后端、架构还是要负责线上稳定性都能从里面找到能直接用的东西。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 GrafanaErrors错误数量/错误率日志平台、APMDuration请求耗时/延迟分布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.8Maven 3.6Redis 6.xMySQL 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 zoneseckill_api:10m rate10r/s; upstream backend_seckill { server 127.0.0.1:8080; } server { listen 80; location /api/seckill/ { # burst 表示允许瞬时超过 rate 的请求数nodelay 表示超出的请求直接拒绝 limit_req zoneseckill_api burst20 nodelay; proxy_pass http://backend_seckill; } } }配置说明rate10r/s表示每个 IP 每秒最多通过 10 个请求burst20表示允许瞬间有 20 个请求排队等待超过的立即返回 503nodelay表示排队请求不进行延迟处理能处理就立即处理否则直接拒绝。网关层限流的优点是“离用户最近、性能最好”缺点是只能基于网络层信息如 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 DefaultRedisScriptLong 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 序列化器适合这种只存字符串的场景。如果使用RedisTemplateObject, 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 ResultString 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 ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 0; result.message success; result.data data; return result; } public static T ResultT error(String message) { ResultT 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 ResultString 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%APM20:05:00紧急扩容 20 台机器运维操作记录20:15:00服务逐步恢复监控系统20:30:00活动暂停数据校验业务系统时间线还原越详细后续根因分析就越准确。建议在故障期间安排一个人专门记录时间而不是等事后靠大家回忆。6.3 五个为什么根因分析时间线还原之后使用“五个为什么”方法逐层追问找到根本原因。举例问题为什么订单服务线程池打满 为什么1因为下游库存服务响应变慢。 为什么2因为库存服务依赖的数据库出现大量锁等待。 为什么3因为秒杀活动多个请求同时更新同一行库存记录。 为什么4因为库存行是热点行没有做锁粒度优化。 为什么5因为系统没有针对热点行场景做削峰和异步化设计。最终发现根因不是“运维扩容晚了”也不是“测试没测出来”而是“架构上没有针对热点并发场景设计保护机制”。这样的根因才具有改进意义。6.4 改进措施的闭环管理复盘的最后一步是输出改进措施。关键一点是每个措施必须有负责人和截止时间。一个好用的改进事项模板改进项负责人截止时间优先级验证方式优化库存扣减热点行张三下周五P0压测验证增加 Redis 预扣库存李四下周五P0压测验证补充链路监控大盘王五两周内P1故障演练修改重试策略赵六下周三P1Code 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. 总结系统的体面是设计出来的回到“千亿赛道”的“社死”时刻。你会发现那些在大庭广众之下崩溃的系统往往不是输在代码写得不好而是输在缺少对极端场景的敬畏和预案。限流、熔断、降级、削峰、幂等、可观测性、故障演练——这些技术名词背后不是“炫技”而是对系统边界的一种清醒认知。只有承认系统会失败我们才会主动给它套上保护壳。本文用一个简化抢购系统展示了从流量入口到数据库落库的完整保护链路。你可以在此基础上继续扩展比如接入配置中心实现动态限流阈值、增加分钟级订单统计、引入全链路压测平台等等。系统的体面和人的体面一样不是靠运气而是靠一次次故障后的复盘、一次次压测中的加固、一次次演练里的修补慢慢换来的。希望这篇文章能帮你在下一次“关键时刻”来临时多一份从容。
返回列表