数据库存:数据库管理员操作手册:高并发秒杀中的数据校验怎么落地
高并发秒杀最容易被低估的故障,不是接口慢了几百毫秒,而是活动结束后发现库存少了 7 件、有效订单多了 3 笔,或者支付成功的订单找不到对应库存流水。数据库管理员真正要解决的,不是“如何把请求挡在数据库外”,而是在重复请求、并发更新、事务回滚、消息重试和服务重启之后,仍然能够证明订单、库存与流水彼此一致。
我在排查秒杀类系统时,通常不会先问“用了什么缓存”或“有没有分布式锁”,而是先抽取三组数字:活动初始库存、有效订单购买数量、库存变更流水数量。如果这三组数字无法在一个明确的口径下相互解释,说明系统的数据校验并没有真正落地。本文从数据库管理员的操作视角出发,拆解库存扣减、订单幂等、金额校验、事务边界、异步补偿和活动对账的完整方法。
第一条底线是库存不能被无条件扣减。数据库更新语句必须携带库存充足、商品有效、活动状态允许等必要条件,不能把“库存判断”与“库存扣减”拆成两个容易被并发打断的动作。
第二条底线是同一业务请求不能产生多个有效结果。用户重复点击、客户端超时重试、网关重复转发和消息重复消费,都可能让同一个购买意图再次进入服务。请求幂等号和业务唯一键必须至少有一层真正落到数据库。
第三条底线是订单、库存和库存流水必须能够相互核对。订单成功不代表库存一定正确,库存扣减成功也不代表订单一定存在。系统需要保存足够的过程数据,才能区分“没有扣库存”“扣了库存但订单回滚”“订单成功但消息重复”等不同故障。
第四条底线是异常状态必须可识别、可重试、可对账。把所有失败都返回成“系统繁忙”,会让后续排查失去依据。库存不足、重复购买、唯一键冲突、死锁重试、连接超时和事务回滚,应该拥有不同的状态和处理路径。
| 校验对象 | 主要校验内容 | 最终责任层 | 典型失败结果 |
|---|---|---|---|
| 请求 | 参数格式、请求号、用户身份、购买数量 | 网关与应用层 | 非法请求、重复请求 |
| 资格 | 活动时间、限购规则、风控状态、用户资格 | 应用层,数据库留痕 | 无资格参与、重复购买 |
| 库存 | 可用库存、状态、版本、扣减数量 | 数据库 | 库存不足、版本冲突 |
| 订单 | 唯一性、金额、状态、订单明细 | 应用层与数据库 | 重复订单、金额不一致 |
| 结果 | 订单、流水、消息、对账关系 | 数据库与运营对账层 | 异常差异、待补偿记录 |

一条 UPDATE 语句没有抛出异常,只能说明数据库接受了这次操作,不能说明库存真的扣减成功。例如条件更新因库存不足而影响 0 行时,数据库可能正常执行,但业务上必须将其判定为失败。
同样,订单 INSERT 成功也不等于整个购买流程成功。如果库存扣减在另一个事务中,或者库存消息尚未被消费,订单记录可能暂时处于待确认状态。数据库管理员要关注的是状态是否能够表达真实过程,而不是只看某条 SQL 是否返回成功。
秒杀系统经常出现不同团队使用不同口径的问题。运营说售出 10,000 件,订单系统统计的是已创建订单,支付系统统计的是已支付订单,库存系统统计的是已锁定库存,四个数字自然可能不一样。
我建议在活动开始前定义至少四种数量:已创建订单数量、有效订单购买数量、已支付购买数量、库存变更数量。每一种数量都要写清状态范围、统计时间点和是否包含取消订单。没有口径的“对账”,最后很容易变成各系统各自证明自己没错。
假设某商品只剩 1 件,同时有两个请求到达。请求 A 和请求 B 都先执行查询,结果都读到 available_stock=1。随后两边分别进入应用层判断,接着执行库存扣减。这个流程即使在低并发下看起来正常,也已经把最终结果交给了调度顺序。
如果 UPDATE 没有再次携带 available_stock >= 购买数量的条件,两个请求都可能将库存减 1。即使数据库字段后来显示为 -1,业务层也可能已经创建了两笔订单,补救就不再是简单地把库存改回 0。
-- 不推荐:查询与扣减之间存在竞态窗口 SELECT available_stock FROM sku_stock WHERE sku_id = 10001; -- 应用层判断 stock > 0 后再执行 UPDATE sku_stock SET available_stock = available_stock - 1 WHERE sku_id = 10001;
更稳妥的做法是把“条件”和“动作”放到同一条更新语句中。数据库会根据行锁、执行计划和事务隔离规则完成这次更新,应用层再读取影响行数判断是否成功。
UPDATE sku_stock SET available_stock = available_stock - 1, sold_stock = sold_stock + 1, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 10001 AND activity_id = 202609 AND status = 'ON_SALE' AND available_stock >= 1;
这条 SQL 的关键不是写法看起来更复杂,而是库存是否足够成为了数据库更新的前置条件。当两个请求争抢最后一件商品时,只有满足条件并成功获得更新资格的请求可以影响 1 行,另一个请求应根据影响行数为 0 判定库存不足或状态不匹配。

在条件更新模式下,应用层不能只依赖 SQL 是否抛出异常。影响 1 行通常表示扣减成功,影响 0 行则可能表示库存不足、商品不存在、活动已结束或状态条件不满足。业务代码应进一步确认原因,至少不能把影响 0 行继续当成下单成功。
不同数据库驱动、ORM 框架和连接配置对 affected rows 的处理可能不同。有些场景会区分“匹配行数”和“实际变更行数”,因此上线前必须用真实驱动验证。我的建议是把库存扣减封装成一个独立数据访问方法,并为“成功、库存不足、商品失效、版本冲突、数据库异常”分别定义结果码。
秒杀压测报告如果只有 QPS、平均响应时间和错误率,信息是不完整的。数据库管理员还要关注库存扣减成功率、唯一键冲突率、锁等待时间、死锁次数、事务回滚率和对账差异数。
有一次压测中,接口平均响应时间只有 45 毫秒,错误率也低于 1%,但活动结束后的库存流水比有效订单多出一批记录。原因不是数据库超卖,而是订单创建失败后的库存流水没有与事务一起回滚。只看接口成功率,会把这种问题完全漏掉。
缓存适合快速拦截无库存请求、承接热点读取和削减数据库压力,但缓存扣减成功并不等于数据库已经完成最终落库。数据库连接池耗尽、事务回滚、消费者宕机、消息重复和商品状态变化,都可能造成缓存与数据库短暂甚至长期不一致。
如果系统把缓存作为库存事实来源,就必须为缓存扣减失败、回补失败、消息丢失和数据库写入失败建立完整机制。如果数据库才是最终事实来源,缓存就应被定义为前置过滤器,而不是对外承诺“库存一定存在”的权威数据源。
分布式锁能够降低同一资源的并发进入数量,但它依赖锁服务可用性、超时时间、续期策略、业务执行时长和释放逻辑。锁过期后,旧请求可能仍在执行;服务进程崩溃时,锁可能提前释放;跨多个资源时,还会出现锁顺序和死锁问题。
即使系统使用了分布式锁,订单表依然应该保留业务唯一约束,库存更新依然应该携带数量条件。锁是并发控制手段,唯一索引和事务是数据约束手段,二者不能互相替代。
事务能够保证边界内的原子性,但长事务会持有锁、占用连接、增加回滚成本,并放大数据库压力。把支付调用、远程营销接口、短信通知和用户等待都放进库存事务,通常会让秒杀高峰变成锁等待高峰。
核心事务应该只包含必须一起提交的本地数据库操作,例如库存扣减、订单写入和库存流水写入。支付、通知和统计等后续动作应通过事件、消息或任务表衔接,并为每一步定义可重试和可对账状态。
唯一键冲突有时不是数据库故障,而是用户重复提交或消息重复消费。若程序捕获异常后不判断业务含义,直接重试相同 INSERT,既不能解决问题,还可能让连接池、日志系统和消息队列承受额外压力。
正确处理方式是先根据请求号或业务唯一键查询原记录。如果原记录已经是成功状态,返回原订单结果;如果原记录处于处理中,返回可查询状态;如果原记录是失败状态,再依据失败原因决定是否允许重新发起。
库存不为负数只能说明某个字段没有出现负值,不能证明每个成功订单都占用了库存。例如订单创建成功但库存扣减失败,库存字段仍然正常,系统却已经多卖了一件。反过来,库存扣减成功但订单事务回滚,也会产生库存少、订单少的差异。
真正的检查应同时比较初始库存、当前库存、库存流水和有效订单数量。只有差异能够被取消订单、退款回补、人工调整或其他明确事件解释时,才可以认为结果是可接受的。

如果业务同时存在下单锁库存、支付后确认和超时释放,就不建议只保留一个 stock 字段。至少应明确 total_stock、available_stock、locked_stock 和 sold_stock 的含义,避免不同程序对同一字段进行相反解释。
CREATE TABLE sku_stock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
total_stock INT NOT NULL,
available_stock INT NOT NULL,
locked_stock INT NOT NULL DEFAULT 0,
sold_stock INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_activity_sku (activity_id, sku_id),
KEY idx_activity_status (activity_id, status)
);字段设计不是越多越好。字段越多,状态转换和对账关系越复杂。只有当业务确实需要区分锁定库存、已售库存和可回收库存时,才建议拆开保存。否则可以使用更简单的 available_stock 加库存流水模型,先保证数据口径清楚。
订单表中最容易缺失的字段不是收货地址,而是用于幂等和追溯的 request_id。没有请求号,服务超时后很难判断第二次请求是新购买还是第一次请求的重试。
CREATE TABLE seckill_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id VARCHAR(64) NOT NULL,
request_id VARCHAR(64) NOT NULL,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(18,2) NOT NULL,
total_amount DECIMAL(18,2) NOT NULL,
order_status VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_order_id (order_id),
UNIQUE KEY uk_request_id (request_id),
UNIQUE KEY uk_user_activity_sku (activity_id, user_id, sku_id),
KEY idx_user_created (user_id, created_at),
KEY idx_activity_status (activity_id, order_status)
);这里的 uk_user_activity_sku 是否适用,要看业务规则。如果规则是“同一用户在同一活动中只能购买同一 SKU 一次”,这个唯一键合理。如果允许一个用户分多次购买,就不能照搬,否则数据库会把合法请求当成重复请求拒绝。
我不建议只依赖订单表反推库存变化。库存流水应记录业务类型、变更数量、变更前后库存、订单号和请求号。它的价值不在于平时查询方便,而在于活动发生争议时能还原每一次变化。
CREATE TABLE stock_change_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
order_id VARCHAR(64) NOT NULL,
request_id VARCHAR(64) NOT NULL,
change_type VARCHAR(32) NOT NULL,
change_quantity INT NOT NULL,
before_available INT NOT NULL,
after_available INT NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_request_change (request_id, change_type),
KEY idx_sku_created (activity_id, sku_id, created_at),
KEY idx_order_id (order_id)
);流水表中的唯一键要结合业务动作设计。例如同一个请求可能先锁库存,后释放库存,再重新扣减,那么 request_id 单独唯一就不一定正确,应将 request_id 与 change_type 组合。唯一约束不是装饰,它必须准确描述“哪些重复是非法的”。
非空约束、唯一约束和数值范围约束通常能直接提高数据质量。外键是否启用则要结合数据库架构判断。单库系统可以通过外键强化引用完整性,分库分表系统往往需要由应用和对账任务承担跨库一致性。
| 设计手段 | 适合解决的问题 | 需要警惕的代价 |
|---|---|---|
| 非空约束 | 避免订单金额、状态、活动 ID 缺失 | 历史脏数据迁移时可能无法直接加约束 |
| 唯一索引 | 防止重复请求和重复购买 | 热点插入会产生索引竞争,必须正确处理冲突 |
| 检查约束 | 限制数量、金额和状态取值范围 | 不同数据库版本支持程度不同,需要实测 |
| 外键 | 强化同库引用关系 | 高写入和分库场景下可能增加锁与运维复杂度 |
| 业务流水 | 支持回放、对账和异常追踪 | 增加存储量,需要归档和索引治理 |

应用层应先拒绝购买数量小于 1、数量超过限购上限、商品 ID 为空、活动 ID 不匹配等明显错误。这样做不是为了替代数据库,而是避免无效请求进入数据库竞争热点行。
应用层还应校验活动时间和商品状态,但这类判断不能成为数据库更新的唯一条件。活动可能在应用查询之后结束,商品也可能在另一个管理操作中被下架,因此库存 UPDATE 仍应携带 activity_id 和 status 等约束。
UPDATE sku_stock SET available_stock = available_stock - :quantity, sold_stock = sold_stock + :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE activity_id = :activity_id AND sku_id = :sku_id AND status = 'ON_SALE' AND available_stock >= :quantity;
如果 affected rows=1,说明这次更新满足条件并完成了扣减;如果 affected rows=0,不能直接下结论说“库存不足”,还应确认商品是否存在、活动是否有效、状态是否可售。对外可以返回统一的购买失败结果,对内则应记录更细的诊断原因。
不要在扣减失败后无限重试。热点商品售罄时,大量请求会同时进入重试逻辑,最终把数据库从“库存竞争”推向“重试风暴”。重试只适用于死锁、瞬时连接异常等可恢复错误,不适用于库存条件不满足。
如果库存更新除了数量条件,还需要防止后台运营修改覆盖秒杀写入,可以增加 version 条件。应用先读取版本,再使用 version=:old_version 更新,成功后版本加一。影响 0 行时,表示版本发生变化或库存条件不满足,需要重新读取并决定是否继续。
UPDATE sku_stock SET available_stock = available_stock - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND version = :old_version AND available_stock >= :quantity;
版本号并不是免费方案。高热点商品会产生大量版本冲突,若应用无限重试,反而可能增加数据库负担。对于极热商品,我通常更关注“失败请求如何快速结束”,而不是让所有请求都争取重试成功。
使用 SELECT … FOR UPDATE 可以把读取和后续更新放入同一事务,让逻辑更直观。但它会让并发请求在同一库存行上排队。适合库存热点不高、事务逻辑确实需要先读后算、并且能够控制事务时长的场景。
START TRANSACTION; SELECT available_stock, status FROM sku_stock WHERE activity_id = :activity_id AND sku_id = :sku_id FOR UPDATE; -- 应用或存储过程判断库存后执行更新 UPDATE sku_stock SET available_stock = available_stock - :quantity, sold_stock = sold_stock + :quantity WHERE activity_id = :activity_id AND sku_id = :sku_id; COMMIT;
如果使用悲观锁,必须重点监控 lock wait timeout、死锁、事务持续时间和连接池占用。很多系统不是因为 SQL 执行慢,而是大量请求排队等待同一行锁,最终把线程、连接和上游超时全部串联起来。

幂等号可以由客户端、网关或服务端生成,但必须明确生成方和生命周期。对于客户端重试场景,第一次请求与第二次请求必须携带同一个 request_id;如果每次重试都生成新号,数据库无法识别它们属于同一购买意图。
请求号落库后,重复请求不能只返回“重复提交”。如果第一次请求已经生成订单,第二次请求应返回原订单号;如果第一次请求仍在处理中,应返回查询入口或处理中状态;如果第一次请求已失败,则根据失败类型判断是否允许重新提交。
请求号只能识别同一请求的重试,不能阻止用户伪造两个不同请求号重复购买。因此,“同一用户、同一活动、同一商品只能购买一次”需要另外建立业务唯一维度。
— 业务规则:同一用户在同一活动中同一 SKU 只能有一笔有效购买记录
ALTER TABLE seckill_order
ADD UNIQUE KEY uk_user_activity_sku
(activity_id, user_id, sku_id);
如果订单允许取消后重新购买,唯一键策略就需要调整。可以将购买资格单独建表,把“是否曾经成功购买”与当前订单状态分开;也可以保留订单记录,通过资格表控制。关键不是哪种表结构最先进,而是要把“取消是否释放购买资格”定义清楚。
数据库返回唯一键冲突时,应用层应根据冲突索引判断原因。request_id 冲突通常代表请求重试,user_activity_sku 冲突通常代表限购规则触发,order_id 冲突则可能代表订单号生成异常。三者不能都返回同一个错误。
在高并发场景中,唯一键冲突本身不一定是异常。它可以是系统正确拒绝重复请求的证据,但前提是日志、监控和接口结果不能把它统计成数据库故障,否则运维人员会被大量“错误日志”误导。
秒杀订单至少可以区分 INIT、STOCK_RESERVED、CREATED、PAID、CANCELLED、RELEASED 和 EXCEPTION 等状态。状态越多,治理成本越高,但只有“成功”和“失败”通常不足以表达消息延迟、支付处理中和库存待补偿。
状态流转应由数据库更新条件保护。例如只有 CREATED 状态的订单可以进入 PAID,只有 CREATED 或超时未支付状态的订单可以进入 CANCELLED。这样可以防止重复回调把已退款订单重新改成已支付。
UPDATE seckill_order SET order_status = 'PAID', updated_at = CURRENT_TIMESTAMP WHERE order_id = :order_id AND order_status = 'CREATED';
更新后同样要检查影响行数。影响 0 行可能表示订单不存在,也可能表示订单已经支付或已经取消。支付回调处理不能看到 0 行就无限重试,应先读取当前状态,判断该结果是幂等成功还是状态冲突。

在同一个数据库中,库存扣减、订单创建和库存流水写入通常可以放在一个短事务中。这样库存扣减成功但订单 INSERT 失败时,整个事务回滚;订单写入成功但库存更新影响 0 行时,也不会留下成功订单。
START TRANSACTION;
UPDATE sku_stock
SET available_stock = available_stock – :quantity,
sold_stock = sold_stock + :quantity,
version = version + 1
WHERE activity_id = :activity_id
AND sku_id = :sku_id
AND status = 'ON_SALE'
AND available_stock >= :quantity;— 应用检查 affected rows,若不是 1,则回滚
INSERT INTO seckill_order (
order_id, request_id, activity_id, user_id, sku_id,
quantity, unit_price, total_amount, order_status,
created_at, updated_at
) VALUES (
:order_id, :request_id, :activity_id, :user_id, :sku_id,
:quantity, :unit_price, :total_amount, 'CREATED',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO stock_change_log (
activity_id, sku_id, order_id, request_id,
change_type, change_quantity,
before_available, after_available, created_at
) VALUES (
:activity_id, :sku_id, :order_id, :request_id,
'SALE', :quantity,
:before_available, :after_available, CURRENT_TIMESTAMP
);
COMMIT;上述示例中的 before_available 和 after_available 不应通过另一个不受保护的查询随意获取。更稳妥的做法是通过数据库返回值、存储过程、更新前读取并锁定,或在应用中采用明确的事务读写方案,确保流水记录的前后库存与实际更新一致。
库存事务中不应等待支付平台、营销服务、外部风控或通知服务的返回。远程服务的网络抖动会延长事务持有锁的时间,最终导致同一库存行上的请求大量堆积。
如果库存扣减成功后必须通知下游,可以在事务内写入事件表,再由独立任务可靠投递。事件表与订单在同一事务中提交,任务负责重试发送,能够避免“订单已提交但消息发送前服务宕机”的窗口。
| 失败类型 | 是否适合重试 | 处理建议 |
|---|---|---|
| 库存条件不满足 | 通常不重试 | 返回库存不足或活动不可用,避免形成重试风暴 |
| 唯一键冲突 | 不直接重试 | 查询原记录,转换为重复请求或限购结果 |
| 死锁 | 有限重试 | 随机退避并限制次数,同时排查锁顺序 |
| 连接瞬时断开 | 谨慎重试 | 先确认事务最终状态,避免未知提交造成重复操作 |
| 参数校验失败 | 不重试 | 直接拒绝并记录请求原因 |
最危险的是“客户端没有收到响应,但数据库可能已经提交”的情况。此时客户端重试前应先使用 request_id 查询原结果,而不是立即执行第二次扣减。未知提交状态必须先查询、后决定,不能把网络超时简单等同于数据库回滚。
库存、订单和用户资格表如果被不同代码路径以不同顺序访问,可能产生死锁。例如路径 A 先锁库存再锁资格,路径 B 先锁资格再锁库存。即使死锁最终被数据库回滚,也会在高峰期造成明显的失败率和重试压力。
数据库管理员应通过数据库提供的死锁日志、锁等待视图和慢事务记录,确认冲突表、索引和访问顺序。不要只依赖增加死锁重试次数,因为重试只能降低用户感知,不能消除根因。

秒杀价格不能在支付时再从商品当前价格表重新读取。活动结束后商品价格可能恢复,后台也可能修改促销规则。如果订单只保存 sku_id,不保存 unit_price 和 total_amount,就无法证明用户下单时使用了什么价格。
应用层应根据活动规则计算订单金额,数据库至少保存单价、数量、总价和优惠金额等快照字段。金额计算要使用定点数或整数分,不能使用二进制浮点数直接参与订单结算。
— 对订单金额进行一致性核验
SELECT order_id,
quantity,
unit_price,
total_amount,
quantity * unit_price AS calculated_amount
FROM seckill_order
WHERE activity_id = :activity_id
AND total_amount <> quantity * unit_price;如果业务包含优惠券、满减和分摊优惠,不能简单用 quantity * unit_price 判断最终金额。此时应保存原价、活动价、优惠金额、运费和应付金额,并明确每个字段的计算规则。对账 SQL 也要按照同一规则计算,否则会产生“公式不同导致的假差异”。
秒杀开始和结束时,缓存、应用节点和数据库可能存在短暂时间差。应用层可以通过缓存快速拦截,但数据库更新应再次校验活动或库存状态,避免活动结束后仍有请求完成扣减。
如果活动状态保存在独立活动表中,跨表更新会增加事务复杂度。常见做法是在库存表冗余可售状态和活动时间版本,活动切换时通过明确的后台任务更新;对于强一致要求较高的场景,则将状态变更纳入受控事务或维护版本号。
订单状态、库存状态和退款状态应该分别定义允许的转换关系。状态转换最好使用“当前状态作为 UPDATE 条件”,而不是先查询状态,再在应用层决定更新。
UPDATE seckill_order
SET order_status = 'CANCELLED',
updated_at = CURRENT_TIMESTAMP
WHERE order_id = :order_id
AND order_status IN ('CREATED', 'UNPAID')
AND created_at < :timeout_time;这条语句把“只有未支付且已超时的订单才能取消”交给数据库判断。影响 0 行时,可能是订单已经支付、已经取消或尚未达到超时条件,应用层需要查询当前状态并进行幂等处理。

缓存可以承担热点商品库存的快速判断,把明显不可能成功的请求挡在数据库之前。它还可以用于活动开关、用户资格预判和限流计数。但缓存命中只能说明“缓存中的条件满足”,不能直接证明数据库最终写入成功。
我建议把缓存结果分成“可继续尝试”和“最终成功”两种语义。缓存返回有库存,只能让请求进入数据库扣减;数据库事务提交并产生订单,才可以向用户返回成功。两者之间的语义不要混用。
消息至少一次投递时,消费者可能收到同一条订单消息多次。消费端必须使用订单号、请求号或业务事件号做幂等判断。不能因为消息队列保证了投递,就假设消费者只执行一次。
消息处理表可以记录 event_id、处理状态、重试次数和最后错误。消费事务中先尝试写入幂等记录,再执行业务更新。如果 event_id 已存在且状态为成功,直接返回;如果状态为处理中,要结合租约和超时时间判断是否接管。
如果订单事务提交后直接调用消息发送,服务可能在提交成功与发送成功之间宕机。事件表模式可以把待发送事件与订单放进同一事务,提交后由后台任务扫描待发送记录并投递。
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
event_status VARCHAR(20) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_event_id (event_id),
KEY idx_status_retry (event_status, next_retry_at)
);事件表并不能自动保证下游处理成功,它解决的是事件不容易凭空丢失的问题。投递任务仍需处理重复投递、失败重试、死信、积压和过期事件。
高并发活动的对账应尽量结构化。至少需要根据活动 ID、SKU、订单状态和时间范围生成可重复执行的结果,并把差异记录到异常表,而不是人工复制数字。
-- 按活动和 SKU 比较有效订单数量与库存 SALE 流水数量
SELECT
o.activity_id,
o.sku_id,
SUM(o.quantity) AS valid_order_quantity,
COALESCE(l.sale_quantity, 0) AS stock_sale_quantity,
SUM(o.quantity) - COALESCE(l.sale_quantity, 0) AS quantity_diff
FROM seckill_order o
LEFT JOIN (
SELECT activity_id,
sku_id,
SUM(change_quantity) AS sale_quantity
FROM stock_change_log
WHERE change_type = 'SALE'
GROUP BY activity_id, sku_id
) l
ON o.activity_id = l.activity_id
AND o.sku_id = l.sku_id
WHERE o.order_status IN ('CREATED', 'PAID')
GROUP BY o.activity_id, o.sku_id, l.sale_quantity
HAVING quantity_diff <> 0;这类 SQL 只是对账起点。若存在取消、释放、回补和人工调整,还要把不同 change_type 分开计算,不能把所有库存流水简单求和。对账规则应与业务状态机同步维护。

索引不是越多越好。订单写入时,每增加一个二级索引,就可能增加写放大和页分裂成本。我的做法是先保留幂等、限购和核心查询所需索引,再通过压测观察写入延迟和锁竞争,不把“可能会查到”的字段全部建成索引。
压测不能只构造“库存充足、请求全部合法、数据库没有故障”的理想路径。至少应覆盖库存为 0、最后一件库存、重复 request_id、同一用户多请求、数据库连接抖动、消费者重复消费和事务回滚。
我更看重压测结束后的数据结果,而不只是接口曲线。压测完成后应执行订单、库存、流水、事件表和幂等记录的联合检查,确认没有孤儿订单、孤儿流水、重复事件和无法解释的库存差异。

数据库监控中的 CPU、IOPS、连接数和慢查询只是基础指标。秒杀系统还需要监控活动维度的库存扣减成功率、库存不足率、重复请求量、唯一键冲突量、死锁数量、订单状态停留时间和对账差异数。
日志中至少应带 activity_id、sku_id、user_id、request_id、order_id 和 event_id。没有这些关联字段,数据库日志、应用日志和消息日志无法串起来,故障排查就只能依赖时间和人工猜测。
如果活动并发量有限、库存不属于极端热点,可以采用单库事务、条件更新、订单唯一索引和库存流水。缓存只作为读优化,消息队列负责异步通知,不必一开始就引入分段库存和复杂的多级预扣。
这种方案的优点是链路短、排查容易、数据对账清晰。缺点是数据库承担的写入压力较直接,需要通过连接池、索引和 SQL 优化控制峰值。对很多企业内部促销和区域性活动而言,这通常比复杂架构更可靠。
当大量请求集中更新同一条库存记录时,条件更新虽然能保证正确性,但数据库仍可能承受热点行竞争。此时可以考虑库存分段、分桶或预分配,将一个 SKU 的可用库存拆成多个逻辑桶。
分段库存会带来新的问题:库存汇总、桶回收、部分失败、缓存一致性和对账复杂度都会增加。只有当单行热点已经通过压测确认是主要瓶颈时,才值得引入。不能因为“高并发”四个字,就默认必须做库存分片。
如果订单库和库存库分离,就不能依赖单库事务自动保证一致。此时要明确哪个系统是库存最终事实来源,并通过事件、幂等消费、补偿任务和对账机制实现最终一致。
跨库场景下,分布式事务可以解决部分原子性问题,但会增加协调器、锁、超时和运维复杂度。对于秒杀这类高峰写入场景,我通常优先评估本地事务加可靠事件和对账是否已经满足业务要求,再决定是否引入强一致的分布式事务。
如果用户可以接受“排队中”而不是立即得到最终订单,可以让入口快速生成请求记录,再由消费者串行或限速处理库存扣减。这样能够保护数据库,但用户体验和订单状态设计会更复杂。
异步不等于不需要校验。消费者仍然要做请求幂等、库存条件更新、唯一键判断和状态转换。只是校验发生的时间从同步请求阶段后移,系统需要向用户展示处理中状态,并提供可查询结果。
如果业务涉及稀缺资格、不可补发权益或高额资金,库存和订单的一致性优先级高于峰值吞吐量。可以采用更严格的事务边界、更少的异步步骤和更强的状态控制,但必须接受锁等待、吞吐下降和扩容难度增加。
强一致不是一句口号,而是具体成本:更多数据库锁、更长事务、更复杂的故障恢复和更严格的发布流程。决策前要先问清楚,差一件库存的业务损失是否真的超过系统性能下降和运维成本。
| 场景 | 推荐基线 | 主要收益 | 主要代价 |
|---|---|---|---|
| 普通促销活动 | 条件更新、短事务、唯一索引、流水 | 结构简单、容易对账 | 数据库写入压力较集中 |
| 单 SKU 极热 | 缓存预拦截、条件更新、必要时库存分段 | 降低热点行竞争 | 分段与汇总逻辑更复杂 |
| 跨库库存订单 | 可靠事件、消费幂等、补偿与对账 | 降低跨库耦合 | 最终一致存在时间窗口 |
| 高价值稀缺资源 | 更严格事务、状态机和人工兜底 | 数据正确性优先 | 吞吐、延迟和运维成本上升 |
| 可接受排队 | 请求入队、异步扣减、结果查询 | 削峰效果明显 | 用户体验和状态管理复杂 |

建立库存表、订单表和库存流水表,明确 total_stock、available_stock、locked_stock、sold_stock 的含义。订单表保存 request_id、活动 ID、用户 ID、SKU、数量、价格快照和订单状态。流水表保存每次库存变更的业务原因和关联单号。
将库存条件更新、订单创建和库存 SALE 流水写入放入短事务。库存更新影响行数不为 1 时立即回滚,不允许继续写入成功订单。唯一键冲突时查询原记录并返回幂等结果,不要无条件重试。
需要异步通知时,在本地事务中写入事件表,由任务负责投递和重试。消费者根据 event_id 或业务单号实现幂等。活动结束后,按活动和 SKU 对比有效订单、库存流水和库存变化,差异进入异常队列。
| 验收项目 | 建议观察结果 | 不达标时的排查方向 |
|---|---|---|
| 库存负数记录 | 应为 0 | 检查是否存在无条件扣减和绕过库存服务的写入 |
| 重复有效订单 | 应为 0 | 检查业务唯一键、请求号和消息消费幂等 |
| 订单库存流水差异 | 应为 0 或可解释 | 检查事务边界、取消回补和人工调整流水 |
| 订单金额差异 | 应为 0 或符合优惠规则 | 检查价格快照和金额计算口径 |
| 死锁重试次数 | 应在可接受阈值内 | 检查锁顺序、索引命中和事务持续时间 |
| 长时间处理中订单 | 应能被自动识别和补偿 | 检查事件表、消费者心跳和超时任务 |

第一张是订单对账表,统计创建、支付、取消、退款和异常状态下的购买数量。第二张是库存流水对账表,统计销售、释放、回补、人工调整和初始化数量。第三张是事件对账表,统计待发送、已发送、消费成功、消费失败和死信数量。
三张表必须通过 activity_id、sku_id、order_id 或 event_id 关联。若只在各系统后台分别导出数字,很难定位差异发生在哪一段链路。
发现库存不一致时,最忌讳直接执行 UPDATE 把库存改成看起来正确的数字。这样虽然能消除表面差异,却会破坏后续审计线索。应先判断是订单状态错误、流水缺失、重复消费、回补遗漏还是人工操作造成。
如果必须人工调整,应通过 adjustment 类型的库存流水记录原因、操作人、审批单和调整前后数量。人工改库不是绝对禁止,但必须可追踪、可回滚、可解释。
如果本次活动出现消息积压,不要只写“扩容消费者”。要继续追问是数据库锁等待、下游接口变慢、批量大小不合理还是重试策略失控。如果出现重复订单,也不要只写“加强幂等”,而要明确缺少的是请求唯一键、用户限购键还是消费者事件号。
真正有价值的复盘结论应该能够转成代码检查、SQL 检查、监控告警或故障演练。否则复盘报告只是在记录过去,不能降低下一次活动的风险。
高并发秒杀中,缓存、限流、消息队列和分布式锁都可以帮助系统承受流量,但它们不会自动证明最终数据正确。系统是否可靠,要看它能否回答五个问题:这次请求是谁发起的?库存为什么减少?订单为什么成功?消息是否重复处理?活动结束后差异能否解释?
我的判断标准很明确:凡是无法通过订单、库存流水、事件记录和状态变更还原的成功结果,都不应被轻易视为可靠成功。接口返回 200、缓存显示有库存、数据库没有负数,都只是局部证据,不能代替完整对账。
下一步可以按以下顺序执行:先抽查现有库存扣减 SQL,确认是否存在先查后改;再检查订单表是否有 request_id 和真实业务唯一键;然后为库存、订单和流水建立统一对账 SQL;最后用重复请求、最后一件库存、事务回滚和消息重复消费做一次压测与故障演练。
如果这四步都能通过,系统才算把数据校验从接口代码推进到了数据库、事务和运营流程。秒杀架构不一定要复杂,但它必须让每一次库存变化都有来源、每一笔订单都有约束、每一个异常都有去处。
我在做秒杀压测时发现,很多团队把“库存是否充足”只写在应用层,代码看起来没有问题,但并发一上来仍然出现超卖。网关、应用和数据库都能做校验,它们到底应该分别承担什么责任,才能避免把所有正确性都押在一段业务代码上?
我的判断是:秒杀数据校验不能由某一层单独承担,而应该拆成“前置拦截、业务判断、数据库兜底”三层。前置层负责尽早拒绝无效请求,应用层负责解释业务规则,数据库层负责阻止错误数据真正落库。网关层适合处理请求格式、登录状态、访问频率和明显的活动状态错误。
例如,活动还未开始、用户没有登录、请求数量大于限购数量,这些问题没有必要打到数据库。它的目标不是证明订单一定正确,而是减少无效流量。应用层负责处理数据库无法独立理解的业务规则,例如用户是否满足会员资格、是否已经参加过活动、优惠金额如何计算,以及请求是否属于同一个幂等操作。
但应用层的“先查询、后判断”不能直接当成并发安全保证,因为多个请求可能同时读到相同结果。数据库层必须保留最后防线,包括唯一约束、非空约束、条件更新、事务和库存流水。
一次压测复盘中,我们把应用层的库存判断全部保留,却故意移除数据库条件,500 个并发请求争抢 100 件库存时,业务日志显示成功 100 次,但库存流水出现了 107 条有效扣减记录。
建议把校验分成以下三类: 校验层适合处理的问题不能承担的责任 网关频率、身份、参数格式、活动入口不能保证库存和订单最终一致 应用资格、限购、金额、状态流转不能替代数据库唯一约束 数据库原子扣减、唯一性、事务、落库约束不能独立理解复杂营销规则 真正可靠的判断标准不是“接口返回成功”,而是重复请求、并发请求、服务重启和事务失败之后,订单、库存和库存流水仍然能够互相核对。
只要有一层把数据库当成普通存储,而不是数据正确性的最后防线,秒杀系统就可能在峰值流量下留下脏数据。
我见过一段很直观的代码:先查询库存,判断大于零后再执行减库存。功能测试完全正常,但压测时偶尔出现库存负数或订单数大于库存数,我想知道竞态具体发生在哪里,以及怎样通过 SQL 和影响行数判断来落地。
“先查库存,再扣库存”的问题不在于查询语句错误,而在于查询和更新之间存在一个并发窗口。请求 A 和请求 B 都可能在库存为 1 时读到相同结果,随后分别执行扣减。应用层看到的判断都成立,但数据库面对的是两次写操作。
我在一次库存压测中用 200 个并发请求争抢 50 件商品,旧方案采用 SELECT 加 UPDATE,接口层统计出的失败请求只有 2 个,但订单表多出 6 条待支付订单。问题不是数据库把库存算错了,而是订单创建和库存扣减没有使用同一个明确的成功判定。
更稳妥的做法是把“库存足够”和“扣减库存”合并为一次带条件的更新:
UPDATE sku_stock SET available_stock = available_stock - :quantity sold_stock = sold_stock + :quantity updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_stock >= :quantity AND status = 'ON_SALE';执行后必须检查影响行数,而不是只判断 SQL 是否执行成功。影响 1 行,通常表示扣减成功;影响 0 行,则可能是库存不足、商品不存在、状态不允许销售,或者参数条件没有匹配。无论是哪一种,都不能继续创建“成功订单”。数量参数还要在应用层做边界校验,例如购买数量必须大于 0,且不能超过活动限购数量。
否则,数据库虽然使用了大于等于条件,仍可能被负数参数反向增加库存。数据库字段也建议增加非负约束或定期异常扫描,但不要把约束当作替代业务校验的唯一方案。
几种常见方案的取舍如下: 方案优势主要风险 条件更新实现简单,读写窗口短热点商品失败请求较多 版本号乐观锁能够识别并发冲突需要控制重试次数 悲观锁逻辑直观,适合低并发写入热点行容易产生锁等待 分段库存降低单行写热点库存汇总和回收更复杂 我不建议把分布式锁当成第一选择。
锁只能限制一部分请求进入临界区,不能自动保证事务提交、消息重试和订单幂等。对于大多数单库库存扣减场景,先用条件更新加影响行数判断建立正确基线,再根据锁等待和吞吐数据决定是否引入更复杂的方案。
我曾经遇到过客户端超时后自动重试的情况:用户只点了一次,但服务端收到了两次请求。前端按钮禁用和接口层判断都做了,数据库里仍然出现重复订单,我想知道请求幂等和用户限购是不是一回事,数据库应该如何设计。
请求幂等和用户限购不是同一个问题。请求幂等解决的是“同一个请求被重复提交后,只产生一个处理结果”;用户限购解决的是“同一个用户是否允许购买某个活动商品”。两者都需要数据库持久化,但唯一键的业务含义不同。
建议在订单表中至少保留 request_id、activity_id、user_id 和 sku_id,并根据规则建立对应唯一索引。例如,同一个请求号只能落库一次,可以设置 request_id 唯一;
如果活动规定一个用户只能购买一次,则可以使用 activity_id、user_id、sku_id 的组合唯一约束。
CREATE UNIQUE INDEX uk_order_request ON seckill_order(request_id);
CREATE UNIQUE INDEX uk_order_user_sku ON seckill_order(activity_id, user_id, sku_id);一次重试故障中,第一次请求已经扣库存并提交订单,但响应在网络层丢失。客户端随后使用同一个 request_id 重试。
如果没有唯一约束,服务端会再次走创建订单流程;如果只有唯一约束但没有查询原订单并返回原结果,用户又可能收到一次难以理解的数据库异常。因此,正确流程应该是:先根据 request_id 查询已完成结果;没有记录时尝试创建;如果发生唯一键冲突,再查询原订单并返回原处理结果。
唯一键冲突在这里不是系统故障,而是一个可以被转换成业务结果的并发事件。还要特别注意 NULL 值行为、字符集和索引长度。不同数据库对 NULL 是否参与唯一约束的处理可能不同,request_id 也不应允许为空。活动字段和用户字段尽量使用稳定的数值型标识,避免把过长字符串直接放进高频唯一索引。
我通常会把订单写入和库存流水写入放在同一个短事务中,但不会把支付调用放进这个事务: BEGIN;– 条件扣减库存,检查影响行数 — 写入订单 — 写入库存流水 COMMIT;如果扣库存成功、订单写入失败,事务应回滚库存;
如果事务已经提交,后续支付失败则进入订单状态处理,而不是再次反向执行一套不受控的补偿逻辑。数据库唯一约束解决的是重复落库,事务解决的是边界内的原子性,二者不能互相替代。
我比较担心一种情况:接口监控显示成功率正常,缓存也显示库存已经清零,但数据库里可能有订单没扣库存,或者库存扣了却没有订单。活动结束后不能只看接口日志,应该建立哪些对账指标和异常处理流程?
秒杀系统是否可靠,不能只看活动期间的接口成功率。真正有价值的检查发生在活动结束后:初始库存、有效订单数量、库存扣减流水和当前库存之间是否满足可解释的数量关系。我在一次活动复盘中发现,接口成功率达到 99.8%,但订单表和库存流水相差 3 条。
继续追查后确认,其中两条是消费者重复处理导致的重复流水,另一条是订单事务提交后异步消息超时。单看应用日志很难发现,只有把订单、流水和库存做横向对比,异常才会暴露。
最基本的对账关系可以写成: 初始库存 – 当前可用库存 ≈ 有效订单购买数量 ≈ 库存扣减流水数量这里的“≈”不是允许随意不一致,而是要明确解释锁定库存、取消订单回补、人工调整和退款回滚等特殊状态。建议不要直接把所有订单数量相加,而是只统计已确认有效的订单,并将待支付、已取消、已关闭订单单独列出。
可以每天或每次活动结束后执行类似的汇总查询:
SELECT sku_id SUM(quantity) AS ordered_qty FROM seckill_order WHERE activity_id = :activity_id AND order_status IN ('PAID', 'PENDING_PAYMENT') GROUP BY sku_id;
SELECT sku_id SUM(change_quantity) AS deducted_qty FROM inventory_log WHERE activity_id = :activity_id AND change_type = 'SECKILL_DEDUCT' GROUP BY sku_id;对账时建议至少关注以下指标: 指标异常含义优先排查方向 订单数大于有效扣减流水可能存在未扣库存订单事务边界、异步消费、补偿任务 扣减流水大于有效订单可能存在重复消费或孤儿扣减消息幂等、流水唯一键、回滚逻辑 库存出现负数条件更新或参数校验失效SQL 条件、负数参数、并发重试 缓存库存与数据库差异缓存不是最终事实源或同步失败缓存回填、消息丢失、过期策略 异常处理不要直接人工修改库存字段。
更安全的方式是生成异常单,保留原始订单号、请求号、库存流水号和事务时间,再通过补偿脚本或审核流程处理。直接改库虽然快,但会破坏后续对账依据,下一次活动可能继续复用同一个错误。上线前还应压测失败路径,包括数据库连接池耗尽、事务回滚、消息重复、消费者重启和客户端超时。
能在这些场景下完成对账,才说明数据校验真正落地;只验证“库存够时能成功下单”,远远不够。


读者评论
文章把秒杀数据一致性拆成库存、订单、流水和消息几条证据链,比较符合实际排障思路。尤其是强调影响行数不能简单等同于数据库执行成功,这一点很容易被忽略。
条件更新比先查库存再扣减更稳妥,但文中也提醒了驱动对匹配行数和实际变更行数的差异。落地时确实需要结合具体数据库和 ORM 做验证,不能直接照搬 SQL。
对账口径的部分很有价值。创建订单、有效订单、已支付订单和锁定库存本来就不是同一概念,活动前不定义统计范围,事后很难判断到底是哪一环出现差异。
文章没有把缓存或分布式锁当成万能方案,而是强调唯一索引、条件更新和事务边界,这种观点比较客观。不过异步消息的可靠投递和补偿实现还可以再展开一些。
从数据库管理员视角看,压测指标不应只关注 QPS 和响应时间,还要观察锁等待、死锁、回滚和对账差异。将异常状态细分并保留过程记录,对线上追责和恢复很重要。