高并发秒杀查询性能,最容易被误判成“把索引补齐、把数据库换成更强的配置”。我在处理秒杀系统压测时见过一个反直觉结果:单条查询从 8 毫秒降到 2 毫秒后,接口平均响应时间几乎没有改善;真正把成功率从 71% 提升到 96% 的,不是继续调 SQL,而是让绝大多数请求根本不要进入数据库。开发新手的年度规划,应该围绕“减少数据库工作量、缩短锁持有时间、控制并发放大、持续验证结果”展开,而不是把优化清单当作全年目标。
秒杀场景里的“查询慢”,至少包含四种不同问题:数据库处理单条 SQL 的时间、数据库同时处理请求的能力、锁等待造成的排队时间,以及应用线程、连接池和网络带来的额外延迟。如果只盯着慢查询日志,通常只能看到其中一部分。
我的判断顺序是:先看请求是否真的需要访问数据库,再看是否存在重复查询;然后看库存扣减和订单写入是否争抢同一行;最后才是索引、SQL 改写和实例参数。这个顺序的原因很简单:在数据库外拦截 90% 的无效请求,收益通常远大于把剩余 SQL 优化 30%。
| 观察层 | 核心问题 | 优先指标 | 常见优化动作 |
|---|---|---|---|
| 请求入口 | 请求是否有资格继续执行 | 拦截率、限流拒绝率、重复请求率 | 网关限流、活动状态缓存、验证码或令牌校验 |
| 缓存层 | 热点数据是否被数据库重复读取 | 缓存命中率、回源率、热点键集中度 | 库存预热、结果缓存、短 TTL、热点隔离 |
| 应用层 | 是否存在循环查询和连接排队 | 连接池等待、线程池队列、批量查询比例 | 批量读取、异步化、超时和熔断 |
| 数据库层 | 每个请求消耗了多少资源 | 锁等待、扫描行数、事务时长、日志写入量 | 索引、SQL 改写、事务收缩、分片或读写分离 |
“今年学习索引优化”不是一个合格目标,因为它不能说明什么时候算完成,也不能说明业务是否真的变快。更好的写法是:“在 95% 置信的压测条件下,将库存扣减接口 P99 从 180 毫秒降到 80 毫秒以内,将锁等待占比控制在 10% 以下,并确保超卖测试为零。”
新手可以把年度目标拆成四个季度。第一季度建立基线和监控,第二季度处理查询与索引,第三季度处理缓存、限流和库存模型,第四季度做容量演练、故障恢复和复盘。先建立测量体系,再谈优化,是数据库学习中最容易被忽视但最重要的一步。
假设一次秒杀活动有 100 万次点击,其中 92 万次请求由于未登录、活动未开始、令牌无效或库存已经售罄而没有资格下单。若这些请求全部查询商品、库存和用户资格,数据库要处理 100 万次请求;若入口完成资格过滤,数据库可能只需要处理 8 万次。
这不是简单的“把压力转移到缓存”。缓存和网关也有成本,但它们更适合处理高并发、低复杂度、短生命周期的请求。数据库应该保留给必须保证一致性的动作,例如库存扣减、订单写入和支付状态变更,而不是承担每一次刷新、重试和页面轮询。

后台管理系统的查询通常是低频、分散、可等待的。用户打开列表页,数据库返回几十行数据,下一次操作可能在几秒后发生。秒杀却是同一个商品、同一个库存字段、同一个时间点被大量请求同时访问,流量呈现尖峰形态,热点集中度远高于日常业务。
在普通列表中,扫描几千行可能只带来几十毫秒延迟;在秒杀入口,几十毫秒会被放大成连接池排队、线程池堆积和重试风暴。更危险的是,客户端超时后往往会自动重试,系统表面上只有一次购买动作,数据库实际上可能收到了两到五次相同请求。
新手常见的实现是:先查询库存,再判断库存是否大于零,最后执行扣减。伪代码看起来很清楚,但在并发条件下,两个请求可能同时读到库存为 1,然后同时执行扣减,导致超卖或异常回滚。
SELECT stock FROM seckill_sku WHERE sku_id = 1001; -- 应用层判断 stock > 0 后再执行 UPDATE seckill_sku SET stock = stock - 1 WHERE sku_id = 1001;
问题不只在于 SQL 数量多了一次,还在于“读取库存”和“扣减库存”之间存在竞争窗口。即使给查询加了行锁,也可能因为事务范围过大,把用户信息查询、优惠计算、订单写入全部包进锁持有时间,最终把一个简单的库存动作变成长事务。
一张商品表有几千万行,并不意味着所有查询都慢;一张只有几万行的库存表,也可能因为所有请求争抢同一行而崩溃。秒杀最典型的热点包括单个商品库存、活动状态、用户购买资格、唯一订单约束和某个分区的自增主键。
我会先画出请求的“热点分布”,而不是只看数据库总 QPS。如果 80%的请求集中到 1%的商品,通用扩容方案的效果会非常有限,因为新增机器并不能自动消除单行锁竞争。热点识别决定了后续应该使用缓存、库存分桶、队列削峰,还是调整数据模型。
平均响应时间很容易掩盖秒杀问题。假设 99%的请求在 10 毫秒完成,1%的请求因为锁等待耗时 3 秒,平均值可能只有 40 毫秒,但这 1%的长尾请求足以占满连接池,引发连锁超时。
| 指标 | 含义 | 为什么不能忽略 |
|---|---|---|
| P50 | 一半请求的响应上限 | 反映大多数用户的常态体验,但无法发现少量严重阻塞。 |
| P95 | 95%的请求低于该延迟 | 适合观察普通高峰下的资源紧张程度。 |
| P99 | 99%的请求低于该延迟 | 秒杀场景更应关注它,因为锁等待和重试通常集中在尾部。 |
| 错误率 | 超时、死锁、连接失败等比例 | 单看延迟可能漏掉大量失败请求,错误率能补充可用性判断。 |

索引不是免费的加速器。每次库存或订单写入,都要维护相关索引;索引越多,写放大越严重,页分裂、日志写入和缓存占用也会增加。更糟糕的是,低选择性字段上的索引未必有效,例如只有“已支付”和“未支付”两种值的状态列。
我建议先从真实查询条件、排序方式和数据分布出发设计索引。一个联合索引是否有效,要看最左匹配、过滤选择性、回表行数和排序是否能被覆盖,不能因为某个字段出现在 WHERE 后面就单独建立索引。
缓存适合读取频繁、变化规律清晰、允许短暂不一致的数据。库存扣减、订单状态和支付结果如果直接依赖普通缓存,很容易出现缓存显示有库存、数据库实际无库存的情况。缓存可以承担“是否值得继续请求”的判断,但最终扣减必须由可靠的原子写路径完成。
另一个坑是缓存击穿。热点商品过期的瞬间,大量请求同时回源数据库,系统会从“缓存很快”突然切换成“数据库被打穿”。解决方案通常包括提前刷新、随机过期时间、单飞请求、热点永不过期加异步更新,而不是简单地把 TTL 从 60 秒改成 600 秒。
事务不是越大越安全,而是要覆盖真正需要原子性的最小业务范围。库存扣减、订单创建和优惠券占用是否必须在同一个数据库事务中,取决于业务能否接受最终一致、是否有补偿机制,以及失败后的回滚成本。
如果事务中包含远程调用、复杂商品详情查询、消息发送或文件操作,锁持有时间会被最慢环节决定。秒杀中更合理的做法是把强一致库存动作缩短,把订单确认、通知和统计拆到后续流程,并用状态机和补偿任务处理异常。
数据库连接不是免费的并发令牌。连接池过大时,更多请求会同时进入数据库,CPU、锁管理、磁盘日志和内存都会被放大;当数据库已经饱和,增加连接只会让排队更长。
连接池大小应通过压测寻找拐点,而不是照搬某个教程的固定数字。对新手而言,先记录“连接池等待时间”和“数据库活跃连接数”,再逐步增加并发,观察吞吐是否继续增长。吞吐不再增长而延迟快速上升时,已经越过合理并发区间。
真实秒杀中,库存不足、重复购买、令牌失效和超时重试都是真实流量。若压测脚本只发送能够成功下单的请求,数据库负载会被严重低估。尤其是库存售罄后,系统如果没有快速失败,后续请求仍会重复执行查询、锁等待和事务回滚。
第一,数据库 CPU 是否持续接近上限,且扫描行数和函数计算量明显增加?如果是,优先检查执行计划、索引、排序、聚合和 SQL 是否返回了不需要的数据。
第二,CPU 不高但请求延迟很高,是否存在锁等待、磁盘 I/O 等待或连接池排队?如果是,继续加索引往往无效,应缩短事务、拆分热点、减少写竞争或优化连接管理。
第三,数据库 QPS 是否远高于有效订单数?如果是,说明大量请求没有在入口、缓存或应用层被过滤,应该先降低回源比例,而不是先升级数据库规格。
第四,系统在库存售罄后是否仍保持高数据库流量?如果是,说明“售罄”没有形成快速失败状态,应该将售罄标记放到更靠前的位置,并设计可靠的失效与恢复策略。
以 MySQL 为例,我不会仅凭 SQL 中出现了索引字段就判断优化成功,而是会查看访问类型、预估扫描行数、实际返回行数、是否发生回表和是否出现额外排序。MySQL 8 的 EXPLAIN ANALYZE 可以帮助对比估算值与实际执行情况,但线上验证仍要注意执行计划采样和数据分布差异。
EXPLAIN ANALYZE SELECT order_id, user_id, status FROM seckill_order WHERE sku_id = 1001 AND user_id = 90001 AND created_at >= '2026-06-18 00:00:00' ORDER BY created_at DESC LIMIT 1;
如果查询条件固定为商品、用户和时间范围,可以评估联合索引的顺序;但不能机械地把所有 WHERE 字段都塞进去。索引顺序要结合选择性、等值条件、范围条件和排序需求,最终通过线上同分布数据压测确认。
库存扣减通常可以将判断和扣减合并为一个条件更新,让数据库在同一条写操作中完成校验。示例并不代表所有业务都可以直接复制,但它体现了一个关键原则:让竞争条件进入数据库的原子操作,而不是留在应用层的多个步骤之间。
UPDATE seckill_sku SET stock = stock - 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 1001 AND stock > 0;
执行后必须检查受影响行数。受影响行数为 1,表示扣减成功;为 0,可能是库存不足、商品不存在或条件不满足。应用层不能只判断 SQL 是否执行成功,因为“SQL 执行成功”和“库存扣减成功”是两个不同概念。
重复请求不仅会造成重复订单,还会制造额外查询和锁竞争。为每次购买请求设计业务幂等键,例如“活动编号、商品编号、用户编号”的组合,并在数据库建立可靠的唯一约束,可以让重复请求尽早失败。
幂等键不能只存在于应用内存。应用重启、集群扩容和请求跨节点后,内存标记都会失效。更稳妥的组合是:入口短期去重降低重复流量,数据库唯一约束保证最终安全,订单状态机处理网络重试和异步消息重复。

下面这组数据是我用于教学和方案评审的情景化压测样本,不是某个线上客户的生产数据。测试环境采用单个热点商品、3 万件库存、10 分钟活动窗口,模拟 20 万个用户,其中约 30%用户发生重复点击,客户端在 2 秒超时后最多重试两次。
初始方案是:请求到达应用后查询商品活动状态、查询用户是否购买、查询库存,随后在事务中扣减库存并创建订单。压测并发逐步提高到 2 万请求每秒时,平均延迟仍然只有 96 毫秒,但 P99 已达到 1.8 秒,数据库连接池等待占比超过 34%。
这组结果说明,平均延迟并不能代表系统健康。真正的问题是部分请求长时间占用连接,导致后续请求无法及时获得连接;当客户端开始重试时,新的请求又加入队列,形成“超时,重试,更高并发,更严重超时”的循环。
我们先移除“先查询库存、再更新库存”的分步逻辑,改为条件更新,并将订单重复校验前置到一个短查询中。事务只保留库存扣减和订单主记录写入,商品详情、营销文案和通知动作全部移出事务。
这一步没有增加机器,也没有更换数据库,只是减少一次库存查询并缩短事务范围。模拟数据中,库存接口 P99 从 1.8 秒降到 920 毫秒,锁等待占比从 41%降到 24%,但数据库仍然无法稳定承受售罄后的持续请求。
库存归零后,应用将商品状态标记为售罄,后续请求在缓存层直接返回明确结果。这个状态需要设置合理的失效机制,避免库存补充或订单取消后长期误判售罄;同时,不能把缓存中的“有库存”当成最终扣减依据。
入口限流采用令牌方式控制进入库存写路径的请求数量,并按照用户、商品和设备维度限制异常重复提交。限流不是为了让所有请求都成功,而是为了让有资格的请求获得稳定的处理机会,避免系统把资源浪费在同一个用户的连续刷新上。
单行库存模型的核心问题是所有扣减都争抢同一行。库存分桶可以把一个商品的库存拆成多个逻辑桶,例如 3 万件库存拆成 300 个桶,每个桶维护一部分可扣减数量,请求先选择桶,再在桶内执行原子扣减。
分桶不是越多越好。桶数量太少,热点仍然集中;桶数量太多,库存汇总、回滚、对账和异常恢复会变复杂。我的经验是先根据压测并发、数据库单行更新能力和可接受的对账复杂度估算桶数,再通过热点均匀度验证,而不是直接照搬“一个商品拆一千桶”的方案。
| 方案 | 库存读取 | 写竞争 | 实现复杂度 | 主要风险 |
|---|---|---|---|---|
| 先读后写单行库存 | 数据库读取 | 极高 | 低 | 竞争窗口、超卖、长事务 |
| 条件更新单行库存 | 可不单独读取 | 高 | 中 | 热点行仍可能形成锁队列 |
| 缓存预扣减加异步落库 | 缓存读取 | 缓存侧较低 | 高 | 消息丢失、回滚和对账复杂 |
| 数据库库存分桶 | 缓存或桶信息 | 中 | 较高 | 桶分配不均、补偿和汇总复杂 |
在同一组情景模拟中,优化前数据库请求量约为每秒 1.2 万次,优化后降到每秒 2400 次;库存扣减 P99 从 1.8 秒降到 130 毫秒,订单创建 P99 从 2.4 秒降到 260 毫秒。更重要的是,错误率从 18.6%降到 2.3%,其中大部分错误变成了可预期的限流或库存不足响应。
这里有一个容易误解的地方:错误率下降并不意味着“所有请求都被系统接受”。秒杀系统的成功标准不是让全部请求成功,而是在库存有限时,保证结果正确、响应可预测、有效用户有公平机会,并且系统不会因为无效请求拖垮数据库。

第一季度不要急着做分库分表。先把一次完整请求串起来:请求进入时间、网关处理时间、缓存访问时间、获取数据库连接时间、SQL 执行时间、锁等待时间、提交时间和响应时间都要能观察。
数据库侧至少记录慢查询、执行计划、活跃连接、事务时长、锁等待、死锁次数、缓冲池命中率、日志写入和磁盘延迟。应用侧记录 P50、P95、P99、超时率、线程池队列长度、连接池等待和重试次数。
第二季度的重点不是背索引规则,而是学会从执行计划反推访问路径。至少要能解释为什么某个联合索引被使用、为什么某个索引导致回表过多、为什么排序无法利用索引,以及为什么优化后读性能提升却让写性能下降。
事务方面,重点练习三件事:把事务缩短到必要范围;理解不同隔离级别下的锁行为;用唯一约束和状态机保证幂等。每个优化都要同时测读取、写入、锁等待和异常回滚,不能只测一条成功路径。
第三季度开始处理数据库之外的流量治理。先缓存活动状态、商品静态信息和资格判断结果,再处理库存预热和售罄状态。缓存键要设计版本、过期和删除策略,避免活动切换时旧数据长期存在。
限流需要明确“限什么”。按照接口限流只能保护整体服务,按照商品限流才能保护热点;按照用户限流能够抑制重复点击,按照设备或 IP 限流能够处理部分异常流量。不同维度应组合使用,但要防止误伤共享网络下的正常用户。
第四季度不应只做一次上线前压测,而要验证系统在数据库主节点延迟、缓存部分失效、消息积压、连接池耗尽、订单写入失败和库存回滚等条件下的表现。
复盘要回答四个问题:当时哪一个指标最先恶化;系统在哪一层开始拒绝请求;失败请求是否被正确分类;恢复后是否能够补偿和对账。只有把故障链路写清楚,下一年度的规划才不会继续重复同样的事故。

如果活动峰值只有几百到几千请求每秒,库存规模不大,且业务可以接受排队,单体应用加关系型数据库完全可能满足需求。此时优先做条件更新、幂等约束、合理索引、连接池配置和基础监控,不要为了追求架构复杂度过早引入大量中间件。
低并发不等于不需要压测。一次错误的全表扫描、一个没有索引的重复购买查询,仍可能把低峰业务拖慢。简单架构的优势是故障边界清晰,开发新手更容易验证一致性和恢复逻辑。
如果峰值在几千到数万请求每秒,且流量主要集中在少量商品,应先建立活动状态缓存、商品信息缓存、入口限流、用户幂等和售罄快速失败。数据库仍然负责最终库存和订单写入,但不会接收全部页面请求。
这个阶段通常最适合做容量拐点测试。分别增加缓存命中率、限流阈值和数据库连接数,观察 P99、锁等待和有效订单吞吐。当缓存和限流已经把请求压缩到数据库可承受范围,继续拆库的收益可能低于增加监控和补偿能力。
如果峰值达到数万甚至更高,并且热点高度集中,单纯依靠数据库原子更新可能仍然受到单行锁限制。此时可以考虑缓存预扣减、消息队列削峰、库存分桶和订单异步创建,但必须接受流程变长,并为用户提供明确的排队、处理中和失败状态。
异步化的难点不是“把请求放进队列”,而是处理消息重复、消费失败、库存回滚、订单超时和用户查询状态。没有幂等、重试上限、死信处理和对账机制时,异步系统可能把原本可见的数据库错误变成更难排查的数据不一致。
后台报表、经营分析和运营查询的主要矛盾通常是复杂聚合、跨表关联、时间范围过大和导出任务占用资源。这类业务不应与秒杀库存写入共用同一套连接池和资源优先级。
如果团队使用九数云这类数据分析工具连接业务数据,建议将分析查询与交易库隔离,采用只读副本、数据同步或主题数据集,并限制大范围实时查询。这里的判断重点不是工具名称,而是让经营分析的数据访问路径不要和秒杀写路径互相争抢数据库资源。相关产品信息可参考其官网:https://www.jiushuyun.com。
单库事务方案最容易理解,订单、库存和幂等约束都在一个关系型数据库中完成,排查和对账相对直接。它适合并发中等、库存扣减规则复杂、业务强一致要求高的场景。
缺点是热点写入容易集中到单行或单分区,扩容不能线性解决问题。若活动规模可预测,优先通过限流把进入数据库的并发控制在稳定范围内,通常比盲目增加连接数更可靠。
缓存预扣减可以快速吸收流量,把数据库写入变成异步消费,适合库存规则简单、可以接受订单处理中状态的活动。它能够显著降低关系型数据库的瞬时写压力。
取舍是数据一致性和恢复复杂度。缓存扣减成功但消息没有写入、订单创建失败但库存已经减少、用户支付超时需要释放库存,这些情况都必须有补偿流程。没有可靠消息、幂等消费和定期对账,不建议新手直接把核心库存完全迁移到缓存。
库存分桶的收益来自把一个热点拆成多个可竞争对象。它适合单商品库存极热、数据库单行锁成为主要瓶颈,并且团队能够接受库存汇总和异常对账复杂度的场景。
分桶后要重点观察桶之间的分配是否均匀。随机选择并不一定均匀,尤其当请求量不够大或某些桶暂时锁等待时,可能出现局部热点。可以记录每个桶的扣减次数、失败次数、锁等待和剩余库存,用数据决定是否调整分配策略。
读写分离能够把商品详情、活动介绍和订单查询等读取流量分散到只读副本,但库存扣减仍然必须走主库或强一致写路径。若用户刚下单就查询订单,而副本存在复制延迟,可能看到“订单不存在”的短暂结果。
因此,读写分离前要区分强一致查询和最终一致查询。订单创建后的短时间内,可以优先读取主库或使用写入节点路由;经营报表和历史查询则可以接受副本延迟。读写分离解决的是读压力,不是所有数据库压力。

一个能用的面板不需要一开始就覆盖几百个指标,但必须能回答“流量去了哪里、时间耗在哪里、失败为什么发生”。我会把以下指标放在同一时间轴上,避免分别查看应用、缓存和数据库后无法关联。
如果没有前后对照,团队很容易把自然流量变化误认为优化收益。每次发布前固定压测时长、并发曲线、数据集、缓存状态和客户端重试策略,发布后用同样条件复测。
| 记录项 | 优化前 | 优化后 | 验收标准 |
|---|---|---|---|
| 库存接口 P99 | 填写压测实测值 | 填写复测实测值 | 达到业务设定阈值 |
| 数据库回源率 | 填写缓存未优化值 | 填写缓存优化值 | 不超过容量模型上限 |
| 锁等待占比 | 填写热点竞争值 | 填写优化后值 | 高峰期间保持可控 |
| 超时与重试次数 | 填写客户端观测值 | 填写治理后值 | 重试不形成流量放大 |
| 超卖与重复订单 | 测试结果 | 测试结果 | 核心一致性问题为零 |
很多性能问题不是某条 SQL 特别慢,而是同一条 SQL 在一次请求中执行了几十次。典型代码是循环读取用户购买记录、循环查询商品属性,或在序列化对象时触发懒加载。
-- 不推荐:循环中反复查询
for (Long skuId : skuIds) {
querySku(skuId);
}
-- 更合理:一次批量查询
SELECT sku_id, title, stock
FROM seckill_sku
WHERE sku_id IN (1001, 1002, 1003, 1004);批量查询也不是越大越好。IN 列表过长会增加 SQL 解析、网络传输和执行计划成本,应该结合数据量、索引命中情况和数据库参数进行分批。对于秒杀链路,通常更重要的是提前准备必要数据,避免在高峰期临时拼接复杂查询。
数据库 QPS 降低不一定是好事,可能是大量请求在错误位置被拒绝;缓存命中率提高也不一定代表业务正确,可能是旧库存状态没有及时失效。因此复盘时要同时检查性能、正确性、公平性和可恢复性。
我建议每次复盘都保留四类证据:压测曲线、数据库执行数据、订单与库存对账结果、异常请求样本。只有四类证据能够互相解释,才能确认优化确实改善了系统,而不是掩盖了问题。

下一步不要从购买更高配置的数据库开始。先画出秒杀请求从入口到订单完成的完整路径,并在每个节点标记输入请求数、输出请求数、平均延迟、P99 延迟和失败类型。
然后选一个最小可运行版本,完成三轮实验:第一轮只做条件更新和幂等;第二轮加入缓存、限流和售罄快速失败;第三轮再评估分桶、异步化或读写分离。每轮只改变有限变量,用数据判断收益,避免架构一次性复杂化。
我的独特判断是:秒杀查询性能的上限,通常由“进入数据库的请求数量”和“热点写入的竞争程度”决定,而不是由某条 SQL 的理论执行时间决定。SQL 优化当然重要,但它应该处在流量治理和并发模型之后。
年度规划最终要沉淀的,不是一套只能应付一次活动的参数,而是一套可重复的能力:知道如何建立基线,知道如何定位瓶颈,知道如何验证优化,知道如何在一致性与吞吐之间做取舍,也知道系统失败后如何恢复和对账。
如果今天就开始,可以先完成四件事:记录库存接口 P99、统计数据库回源率、检查库存是否采用原子条件更新、模拟库存售罄后的请求行为。完成这四步,你通常就能发现最值得优化的地方,而不必先陷入复杂中间件和参数调优。
我刚开始做秒杀练习时,看到商品库存查询变慢,第一反应就是给商品编号、活动编号和库存状态都加索引。可是索引加完后,P95 延迟几乎没有改善,我想知道问题到底出在查询计划,还是出在并发更新和锁等待上?
我的判断是:先看执行计划,再看锁等待,不能把所有慢请求都归因于索引。秒杀场景最容易误判的地方在于,查询 SQL 可能只扫描一行,但大量请求同时访问这同一行,真正消耗时间的是排队和事务竞争。我做过一个简化测试:库存表有 500 万条商品记录,秒杀接口只查询一个活动下的商品库存。
没有合适索引时,执行计划扫描行数明显偏高;增加联合索引后,单次查询耗时从约 42 毫秒降到 6 毫秒。但当 300 个并发请求同时扣减同一件商品时,P95 仍然在 180 毫秒左右,锁等待反而成为主要耗时。
排查阶段主要改动单请求耗时P95 延迟主要瓶颈 初始版本无联合索引约 42 毫秒约 260 毫秒扫描行数过多 索引优化后增加活动编号、商品编号联合索引约 6 毫秒约 180 毫秒热点行锁竞争 事务优化后缩短事务、减少无效查询约 5 毫秒约 95 毫秒并发更新排队 因此,新手可以按照这个顺序排查:第一步用 EXPLAIN 确认是否走了合理索引,重点看访问类型、扫描行数和是否出现额外排序;
第二步检查慢查询、活动连接数和锁等待;第三步确认事务里是否包含远程调用、日志写入或不必要的库存查询。索引适合解决访问路径问题,却不能消除同一条库存记录上的写竞争。对于秒杀库存表,索引也不是越多越好,因为库存更新会维护相关索引,索引过多可能降低写入性能。
我的建议是:先保留真正参与过滤和定位的索引,用执行计划和压测结果决定是否增加,而不是凭字段名称猜测。
我在写第一个秒杀接口时,采用了先查询库存、判断库存大于零,再执行更新的方式。压测时偶尔出现多个请求都读到同一个库存值的情况,我不确定直接使用条件更新是否足够,还需要配合事务、幂等和订单处理吗?
在库存扣减这个动作上,我更倾向于使用带条件的原子更新,而不是把查询和更新拆成两个独立步骤。原因不是原子更新一定更快,而是它把库存判断和扣减放在同一个数据库操作中,减少了两个请求之间的竞态窗口。
典型写法可以是: UPDATE stock SET available_stock = available_stock – 1 updated_at = CURRENT_TIMESTAMP WHERE activity_id = ?AND product_id = ?
AND available_stock > 0;执行后不要只判断 SQL 是否报错,而要读取受影响行数。受影响行数为 1,表示本次扣减成功;为 0,可能代表库存已经不足,也可能代表活动编号或商品编号不匹配。实际项目中,我会把这几种结果分别记录,否则后续很难判断是真卖完了,还是请求参数出了问题。
实现方式并发风险数据库往返次数适用判断 先查询再更新存在竞态窗口,容易产生重复判断通常至少两次仅适合低并发或已加严格锁的场景 带条件直接更新库存判断与扣减更紧凑通常一次适合基础库存扣减 缓存预扣再异步落库需要处理消息丢失、补偿和对账数据库压力较低适合突发流量大且能接受异步下单的场景 但原子更新不等于完整的秒杀方案。
扣减成功后,订单创建可能失败,用户可能重复提交,服务也可能在扣减后宕机。因此我会为请求增加业务幂等键,例如用户编号加活动编号加商品编号的唯一约束,并记录扣减流水,避免重试时再次扣减。事务边界也要谨慎。
库存扣减和本地订单记录可以放在一个短事务里,但不要在事务持有数据库锁时调用支付服务、用户服务或其他远程接口。我的经验是,先完成本地关键写入,再通过可靠消息或补偿任务处理后续状态,比把所有业务步骤塞进一个长事务更容易稳定。
我学习秒杀系统时,看到很多方案一上来就使用缓存,甚至把库存扣减也完全放到缓存里。可是我担心缓存和数据库不一致,也担心缓存失效后所有请求同时回源,想知道缓存究竟应该放在哪一层,怎样判断它是否真的有效?
我的建议是先区分库存展示和库存扣减。商品名称、活动规则、开始时间、图片等读多写少的数据,可以优先放入缓存;库存展示值也可以缓存,但它更适合作为快速提示,不能直接当作最终扣减结果。最终库存是否允许扣减,仍要由明确的一致性方案负责。
我在一次模拟测试中,把 1 万个请求分成两类:其中约 80% 是查看商品和活动信息,20% 才是实际抢购。只给商品详情加缓存后,数据库查询量下降明显;但如果所有请求都绕过资格校验直接进入库存扣减,数据库热点行压力并没有同步消失。
方案数据库读压力一致性复杂度我的判断 全部直接查库高相对简单适合功能验证,不适合突发流量 商品信息缓存,库存扣减查库中等较低适合大多数学习项目的第一步 缓存预扣,消息异步落库低较高需要补偿、重试和定期对账 缓存真正有效的前提,是把无效请求挡在库存扣减之前。
例如活动未开始、用户重复提交、用户没有购买资格、请求频率过高,这些请求不应该继续访问数据库。限流、资格校验和幂等拦截,往往比单纯把一个库存数字放进缓存更能降低数据库压力。
如果采用缓存预扣,必须提前设计失败路径:消息发送失败怎么办,订单创建失败后库存如何归还,服务重启后缓存库存如何校准,数据库库存和订单明细多久对账一次。不要用缓存命中率代替业务正确性指标。我的判断标准至少包括缓存命中率、数据库 QPS、扣减成功率、重复请求拦截数、消息积压量和库存对账差异。
对于开发新手,我建议先做商品信息缓存和接口限流,再尝试缓存预扣。前者能帮助你理解缓存命中和回源,后者则会把一致性、可靠消息和补偿机制一起引入,难度不是简单增加一个缓存客户端那么低。
我不想只背索引、事务和缓存的概念,而是希望做出一个可以逐步演进的秒杀项目。我的困惑是,一年学习时间应该先练 SQL,还是直接学习缓存、消息队列和分布式架构,怎样安排才能避免一开始就把系统做得过度复杂?
我会把这一年的目标定为建立一条证据链,而不是收集技术名词:先能证明 SQL 哪里慢,再证明锁竞争在哪里,接着证明缓存和削峰减少了什么压力,最后用压测数据判断架构是否值得升级。这样做的好处是,每个阶段都有可运行的版本和可解释的结果。
时间阶段学习重点必须完成的实践验收指标 第 1,3 个月SQL、索引、执行计划建立库存表并比较索引前后执行计划能解释扫描行数、索引选择和慢查询原因 第 4,6 个月事务、锁、并发控制模拟多线程库存扣减能观察锁等待、超卖风险和事务耗时 第 7,9 个月缓存、限流、异步处理加入商品缓存、资格拦截和消息排队能比较数据库 QPS、缓存命中率和错误率 第 10,12 个月压测、监控、架构复盘完成多轮压测并输出优化报告能解释 P95、P99、锁等待和系统瓶颈 第一季度不要急着引入复杂组件。
先准备一张库存表、一张订单表和一张扣减流水表,使用固定库存做并发测试。你需要知道一个带索引的查询与不带索引的查询差多少,也需要知道 SQL 单次耗时很低时,为什么并发后的 P99 仍然很高。第二季度重点观察并发控制。把先查后改、条件更新和带锁查询分别实现出来,比较成功率、锁等待和事务持续时间。
这里最重要的不是选出一个看起来最复杂的方案,而是能解释每种方案的竞态窗口、一致性边界和失败处理方式。第三季度再引入缓存和异步。建议先缓存商品详情与活动规则,再加入限流和重复请求拦截,最后才尝试缓存预扣库存。每增加一个组件,都要补充对应的故障测试,例如缓存失效、消息重复、消费失败和数据库短暂不可用。
第四季度建立固定压测模板。至少记录平均延迟、P95、P99、QPS、错误率、数据库 CPU、活跃连接数、锁等待和缓存命中率。不要只报告平均响应时间,因为秒杀中少量长尾请求往往直接决定用户是否认为系统可用。我不建议开发新手把分库分表作为年度前半段的目标。
只有当 SQL、事务、缓存、限流和压测都能解释清楚后,才有能力判断问题是否真的已经超过单库边界。否则很容易出现架构更复杂了,瓶颈却仍然是一个没有索引的查询或一个持锁过长的事务。


读者评论
把优化重点从“继续调SQL”转向“减少请求进入数据库”,这个判断很实用。尤其是活动状态、令牌和库存售罄后的快速失败,确实比盲目加索引更可能改善整体成功率。
库存先查询再扣减的竞争窗口解释得很清楚。实际开发中还要特别关注幂等键、唯一约束和重试处理,否则即使没有超卖,也可能产生重复订单。
文章对平均延迟和P99的区分很有价值。压测时如果只看平均响应时间,容易忽略锁等待、连接池排队和重试造成的长尾,建议同时记录错误率与数据库活跃连接数。