在“数据库存:架构师效率攻略:用表结构设计加快保证扣减一致性”这个问题里,最容易被忽略的事实是:很多超卖、重复扣款和余额对不上的事故,并不是因为开发人员不会写事务,而是因为表结构没有把“这次扣减是谁发起的、是否已经执行过、当前状态是什么、失败后如何恢复”表达出来。代码可以临时补救,数据模型却决定了系统能否长期承受并发、重试和异常。
我在做库存、额度和账户类系统评审时,通常不会先问“这里要不要加分布式锁”,而会先看四件事:主表是否只保存当前状态,流水表是否记录业务事实,幂等键是否由数据库约束兜底,扣减条件是否真正写进更新语句。只要这四个问题没有答案,后面讨论锁、缓存和消息队列,往往只是把风险从一个地方搬到另一个地方。
库存扣减时,最直观的约束是可用数量不能变成负数。但在真实业务里,数量不为负并不代表系统正确。一次订单可能被重复扣减两次;一次扣减可能成功写入数据库,却因为接口超时被客户端再次提交;订单状态可能已经取消,但冻结库存没有释放;主表显示还有 20 件,流水表却找不到对应的扣减记录。
因此,我会把扣减一致性拆成四个层次。第一层是数值一致,数量、金额或额度满足业务边界;第二层是请求一致,同一业务动作重复到达时只产生一次有效结果;第三层是状态一致,扣减、冻结、释放、确认和撤销之间遵循合法状态流转;第四层是事实一致,当前状态能够被流水、订单或账务记录解释。
| 一致性层次 | 需要回答的问题 | 主要数据结构 | 常见失效表现 |
|---|---|---|---|
| 数值一致 | 扣减后是否仍满足数量或金额约束? | 可用量、冻结量、精确数值字段 | 超卖、负库存、金额精度错误 |
| 请求一致 | 同一个请求重试后是否只执行一次? | 请求号、订单号、唯一索引 | 重复扣减、重复发券、重复记账 |
| 状态一致 | 冻结、确认、释放是否按合法顺序发生? | 状态字段、状态迁移记录 | 取消订单仍占库存、已释放库存再次释放 |
| 事实一致 | 当前余额能否由业务流水解释? | 扣减流水、操作类型、业务关联号 | 无法对账、异常只能人工猜测 |
表格中的分类不是数据库标准定义,而是我在设计评审中使用的实务拆分。它的价值在于避免团队把“事务提交成功”误认为“整个业务链路已经一致”。数据库事务可以保证边界内的原子性,却不能自动解决跨服务消息重复、客户端重试和下游处理失败。

对于单库、单资源、直接扣减的场景,我建议先建立一个最小闭环:资源主表保存当前可用状态;扣减流水表保存业务动作;业务请求号通过唯一约束实现幂等;更新语句通过条件限制保证原子扣减。四者的职责不同,不能用一个字段替代全部能力。
这套方案不意味着所有系统都要使用完全相同的字段,更不意味着它可以处理跨数据库、跨服务的全部事务。但它能把最常见的四类问题分别落到可验证的设计点上,比单纯在应用代码中写“先查询、再判断、再更新”更容易测试,也更容易排查。
假设某个商品可用库存为 5,两个请求分别要扣减 4 件。应用层常见写法是先执行查询,确认库存足够,然后再执行更新。两个事务都可能在更新前读到 5,于是都认为条件成立。若更新语句只是简单地做减法,最终结果可能是负数;若更新语句直接覆盖查询结果,最终结果还可能看起来正常,却已经丢失了一次扣减事实。
-- 风险较高的伪代码流程 SELECT available_qty FROM inventory WHERE sku_id = :sku_id; -- 应用层判断 available_qty >= :amount UPDATE inventory SET available_qty = :calculated_qty WHERE sku_id = :sku_id;
这里真正的问题不是 SELECT 本身,而是“判断条件”和“最终修改”不在同一个数据库决策点完成。查询结果返回到应用层后,数据库已经无法保证这个结果仍然代表最新状态。线程切换、网络延迟、连接池排队和其他事务提交,都可能发生在这两个语句之间。
更稳妥的直接扣减方式,是把资源是否足够写进 UPDATE 的 WHERE 条件。数据库只会对满足条件的那一行执行扣减,应用侧再根据受影响行数判断成功或失败。
UPDATE inventory SET available_qty = available_qty - :amount, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_qty >= :amount AND status = 'AVAILABLE';
如果返回受影响行数为 1,通常表示资源存在、状态符合要求且数量足够;如果返回 0,则可能是库存不足、资源不存在或状态不允许。生产代码不能简单把所有 0 行都转换成“库存不足”,否则会把商品下架、SKU 不存在和数据异常混成同一个错误。
我通常会在应用层增加一次轻量级原因判断,或者在业务层提前读取资源状态,但这次读取只用于生成更准确的提示,不再承担最终一致性判断。最终能不能扣减,仍然以带条件的 UPDATE 结果为准。

条件 UPDATE 能解决单个资源字段的并发扣减,却不能自动解决一次订单需要扣减多个 SKU 的问题。如果订单包含 10 个商品,其中 6 个已经扣减成功,第 7 个库存不足,系统必须明确采用整体回滚、部分成功、预占后确认,还是异步拆单。这个决策属于业务语义,不是 SQL 自己能够决定的。
同样,如果扣减成功后还要发送消息、调用支付服务或通知仓储系统,单条 UPDATE 只能保证数据库内的状态变化。数据库提交成功而消息发送失败时,仍然需要 Outbox、重试、消费幂等或对账任务补齐链路。
库存、账户余额、优惠券额度和周期配额虽然业务含义不同,但都可以抽象为一种“可被消耗的资源”。主表的目标不是记录所有历史,而是让系统快速知道当前还能使用多少、是否允许操作,以及该行最近何时被修改。
CREATE TABLE inventory (
sku_id BIGINT PRIMARY KEY,
total_qty BIGINT NOT NULL,
available_qty BIGINT NOT NULL,
frozen_qty BIGINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL,
CONSTRAINT ck_inventory_qty
CHECK (available_qty >= 0),
CONSTRAINT ck_inventory_frozen
CHECK (frozen_qty >= 0)
);
在支持检查约束的数据库中,`CHECK` 可以作为最后一道防线;在一些历史版本或特定数据库配置下,检查约束可能不会按预期执行,因此仍应在应用层和更新条件中重复表达关键边界。重复表达并不是冗余,而是将错误尽可能拦截在不同层次。
字段设计还要避免一个常见陷阱:把总量、可用量和冻结量只当作三个可以随意修改的数字。如果业务规则是“总量等于可用量加冻结量加已消耗量”,那么这个关系应该通过事务内更新、流水对账或汇总任务保持,而不是寄希望于开发人员每次都记得手工同步。
主表中的 `available_qty = 82` 只能回答当前剩余多少,不能回答为什么从 100 变成 82。流水表需要记录每一次有效业务动作,以及必要的处理状态。对于库存,可以记录下单扣减、取消释放、支付确认、人工修正和盘点调整;对于余额,则需要记录充值、消费、退款、冻结和解冻。
CREATE TABLE inventory_change_log (
change_id BIGINT PRIMARY KEY,
sku_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
biz_order_no VARCHAR(64),
operation_type VARCHAR(32) NOT NULL,
change_qty BIGINT NOT NULL,
before_qty BIGINT,
after_qty BIGINT,
operation_status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (sku_id, request_id, operation_type)
);这里的唯一键不是固定模板。比如同一个订单可能允许“冻结”和“释放”各发生一次,那么唯一键可以包含操作类型;如果同一请求无论操作类型如何都只能执行一次,则唯一范围需要重新定义。唯一索引的组合必须来自业务语义,而不是来自数据库教程中的惯用写法。
`before_qty` 和 `after_qty` 是否保存,也需要结合审计要求判断。它们有利于排查问题,但如果系统存在并发写入、批量修正或跨表更新,就必须明确这些快照是在同一事务中生成的,并且不能把流水快照当作唯一账务真相。
整数库存通常可以使用足够范围的整数类型;金额和计费额度则应使用精确数值类型,避免浮点计算造成尾差。重量、长度和比例类资源需要先定义精度,例如保留 3 位小数,再决定使用定点数还是按最小单位换算成整数。
| 资源类型 | 建议存储方式 | 主要风险 | 设计重点 |
|---|---|---|---|
| 件数库存 | 整数,以最小可售单位存储 | 超卖、单位混用 | 可用量、冻结量、单位换算 |
| 账户金额 | 定点小数或最小货币单位整数 | 浮点尾差、舍入不一致 | 币种、精度、流水和对账 |
| 周期配额 | 整数或定点数加周期字段 | 跨周期误扣、重置竞态 | 周期键、重置状态、幂等 |
| 优惠券库存 | 状态记录或可用额度字段 | 重复领取、状态错乱 | 用户与活动的唯一约束 |

事务可以把多个数据库操作放进一个原子边界,但事务并不会自动替应用决定“数量是否足够”。如果事务内部仍然是先查询、应用层判断、再执行没有条件的更新,那么事务只是把这几个动作包在一起,竞争关系仍然可能存在。
更准确的说法是:事务解决的是一组操作的提交与回滚;条件更新解决的是资源扣减条件与写入动作之间的原子判断;唯一约束解决的是重复业务动作;流水和补偿解决的是事后追踪与异常恢复。它们不是互相替代的关系。
悲观锁可以让事务在读取资源时锁住目标行,适合需要读取后进行多步判断的场景。但锁有三个经常被低估的成本:锁持有时间变长会增加等待;事务异常可能造成连接占用;热点资源上的请求会形成排队。
如果只是做“可用量大于扣减量,然后减少可用量”这一件事,条件更新通常更直接。若业务需要同时读取多项资源、计算复杂规则并写入多张表,才有理由考虑更明确的锁策略。即便使用锁,也必须控制事务范围,避免把远程调用、复杂计算和用户交互放在持锁事务中。
版本号只有参与 UPDATE 条件时才有意义。如果表中有 `version`,但更新语句没有 `WHERE version = :old_version`,它只是一个变化计数器,并不能检测并发覆盖。
UPDATE account_balance SET available_amount = available_amount - :amount, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE account_id = :account_id AND version = :old_version AND available_amount >= :amount;
版本冲突后的策略同样重要。库存扣减通常不适合无限重试,因为热点行可能已经被持续消耗;配置类数据可以重读后重试;账务类操作则可能需要直接失败并交给人工或补偿流程。乐观锁不是“自动成功机制”,而是“发现冲突机制”。
在幂等场景中,唯一键冲突有时不是系统错误,而是说明同一请求已经被处理过。比如客户端第一次请求已经扣减成功,但响应在网络中丢失,第二次请求到达时触发唯一约束。此时正确处理方式通常是查询首次处理结果并返回幂等响应,而不是把用户推向失败页面。
当然,唯一键冲突也可能表示业务数据错误,例如同一个请求号被不同订单复用。系统需要根据请求参数、业务单号和已有流水内容判断是“重复请求”还是“幂等键污染”,不能一概而论。
流水表确实能提高审计和排查能力,但它也会带来写入量、索引维护、归档和查询成本。每增加一个索引,写入事务都可能增加额外开销;每保存一个大字段,都可能影响存储和备份。
我更倾向于把流水字段分成三类:必须用于幂等和对账的核心字段,必须用于合规审计的字段,以及只为排查方便保留的扩展字段。核心字段应保持稳定,扩展信息可以放在结构化扩展列或独立审计表中,避免把主流水表变成无法管理的“万能日志表”。

一个订单包含多个商品时,第一问题不是“要不要分布式锁”,而是订单能否部分成功。如果必须全部扣减成功,主表扣减和流水写入应放进同一个本地事务,任何一项失败都回滚;如果允许部分成功,就需要为每个资源建立独立状态,并让订单进入“部分完成”或“待补货”等明确状态。
如果业务没有回答这个问题,开发人员往往会用数据库默认行为替代业务决策:先扣到的成功,后扣不到的失败。结果就是订单、库存和支付状态各自正确,却组合成了一个用户无法理解的异常结果。
直接扣减适合不可逆或可以通过反向流水恢复的操作,例如从可用库存中减少数量。预占库存则不是简单消耗,而是把资源从“可用”迁移到“冻结”;支付确认再从冻结迁移到已消耗;订单取消则从冻结迁移回可用。
-- 冻结资源:从可用量转移到冻结量 UPDATE inventory SET available_qty = available_qty - :amount, frozen_qty = frozen_qty + :amount, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND status = 'AVAILABLE' AND available_qty >= :amount;
冻结与释放必须使用不同的幂等操作类型。例如同一订单的“冻结”只能成功一次,“释放”也只能成功一次,且释放前要确认冻结确实存在。否则取消接口重复调用时,可能把同一批库存释放两遍。
低并发资源适合使用条件更新和短事务;中等冲突资源可以增加版本号、失败重试和合理退避;结构性热点资源,例如某个爆款 SKU、全站共享配额或单一账户,则需要进一步考虑分桶、预分配、异步排队或按资源拆分。
需要特别注意,拆分热点并不会让总库存凭空增加。假设一条库存记录被拆成 16 个桶,系统还要决定扣减顺序、如何判断全局可用量、如何处理某个桶更新成功而另一个桶失败。热点拆分是性能方案,不是幂等和一致性的替代品。

如果单库事务已经能够覆盖资源主表和流水表,优先把一致性留在数据库内部,通常更容易验证。分布式锁会增加锁服务可用性、锁超时、误释放、客户端重连和故障转移等问题,不能因为“并发高”四个字就直接引入。
当资源跨越多个数据库、多个地域或多个服务边界时,才需要进一步讨论消息最终一致性、业务补偿、分布式事务或流程编排。此时表结构仍然重要,因为状态字段、事件号和补偿次数会决定系统能否恢复,而不是只有中间件配置决定结果。
客户端传来的订单号不一定天然适合作为幂等键。一个订单可能包含多个 SKU,也可能先冻结后确认,因此需要明确幂等的粒度。常见粒度包括“订单加 SKU 加操作类型”“支付流水加账户加扣款类型”或“消息事件号加消费者业务动作”。
请求号生成后,应贯穿日志、流水、消息和补偿记录。这样出现接口超时或消息重复时,系统可以沿着同一个标识查出首次处理结果,而不是只能根据时间和数量猜测。
直接把主表更新放在幂等记录之后,可以让数据库先通过唯一约束确认这次操作是否已经存在。如果记录已经存在,则读取其状态;如果不存在,则创建处理中记录,再执行资源扣减。
BEGIN;
INSERT INTO inventory_change_log (
change_id,
sku_id,
request_id,
biz_order_no,
operation_type,
change_qty,
operation_status,
created_at,
updated_at
) VALUES (
:change_id,
:sku_id,
:request_id,
:biz_order_no,
'DEDUCT',
:amount,
'PROCESSING',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
— 只有 INSERT 成功创建新操作时,才继续执行扣减
UPDATE inventory
SET available_qty = available_qty - :amount,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = :sku_id
AND status = 'AVAILABLE'
AND available_qty >= :amount;— 根据受影响行数更新流水状态
UPDATE inventory_change_log
SET operation_status = 'SUCCESS',
before_qty = :before_qty,
after_qty = :after_qty,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = :sku_id
AND request_id = :request_id
AND operation_type = 'DEDUCT';
COMMIT;这段代码是结构示例,不应直接复制到所有数据库和业务系统。实现时必须处理 INSERT 唯一键冲突、事务回滚、受影响行数为 0、锁等待超时和连接断开等分支。尤其要避免“流水插入成功但主表更新失败后,流水状态仍被标记为成功”的逻辑错误。
库存不足通常是业务失败,不适合无脑重试;锁等待超时可能是技术失败,可以有限重试;数据库连接断开则存在“事务究竟是否提交”的不确定性,需要根据请求号查询结果,而不是直接重新扣减。
| 失败类型 | 是否直接重试 | 推荐处理方式 | 需要保留的信息 |
|---|---|---|---|
| 数量不足 | 通常不重试 | 返回业务失败,记录可用量不足原因 | 请求号、SKU、申请数量、当前状态 |
| 唯一键冲突 | 不重新扣减 | 查询既有流水并返回幂等结果 | 原请求参数、首次处理状态 |
| 锁等待超时 | 有限重试 | 指数退避,超过次数后进入待处理 | 重试次数、等待耗时、资源标识 |
| 连接中断 | 先查询再决定 | 根据请求号确认事务结果 | 事务追踪号、请求号、连接错误 |
| 消息重复 | 不重复执行业务动作 | 以事件号或业务请求号做消费幂等 | 事件号、消费状态、失败原因 |
对冻结库存、优惠券、账户额度这类业务,我不建议只用一个布尔字段表示是否处理。至少要区分处理中、成功、失败、已释放和已补偿等状态,并为每一种状态定义允许的下一步。
状态机的意义不在于让表看起来复杂,而在于让重复调用具备确定答案。没有状态机时,取消、重试和补偿往往会根据几个散落的时间字段进行推断,最终导致同一个业务动作在不同服务中被解释成不同结果。

我们先使用一个便于复现的库存场景:某 SKU 初始总量为 100,可用量为 100;用户创建订单时需要扣减 3 件;支付超时后订单可能取消;接口网关可能因为响应超时自动重试。这个场景不依赖特定企业,也不代表任何公开公司的生产数据,下面的数字仅用于说明数据结构和异常路径。
如果下单即消耗库存,订单创建成功后资源就不能再被其他订单使用;如果下单只是预占,系统应当把 3 件从可用量转移到冻结量,后续支付成功确认消耗,支付失败则释放冻结。两种方案的表结构相似,但状态和反向操作完全不同,不能把“扣减”作为一个没有业务语义的通用接口。
对于下单即扣减的普通库存,可以采用条件更新加扣减流水。假设请求号为 `REQ-20260916-0001`,订单号为 `ORD-10001`,本次申请 3 件,数据库动作应当让主表和流水在同一事务中完成。
BEGIN;
— 先尝试创建本次业务动作
INSERT INTO inventory_change_log (
change_id,
sku_id,
request_id,
biz_order_no,
operation_type,
change_qty,
operation_status,
created_at,
updated_at
) VALUES (
900001,
3001001,
'REQ-20260916-0001',
'ORD-10001',
'DEDUCT',
3,
'PROCESSING',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
— 只有条件满足才扣减
UPDATE inventory
SET available_qty = available_qty - 3,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = 3001001
AND available_qty >= 3
AND status = 'AVAILABLE';— 依据更新结果决定流水为 SUCCESS 或 FAILED
COMMIT;
如果第一次请求在 COMMIT 后响应超时,客户端再次携带相同请求号重试,第二次 INSERT 会触发唯一约束。此时应用不应再执行 UPDATE,而应读取已有流水:如果状态是 SUCCESS,就返回原扣减结果;如果状态是 PROCESSING,则根据事务可见性、超时策略和补偿机制判断是否继续;如果状态是 FAILED,则根据失败原因决定是否允许新的业务动作。
预占场景更适合显式记录操作类型。冻结是 `available_qty – amount`、`frozen_qty + amount`;释放是相反方向;确认则可能是 `frozen_qty – amount`、`consumed_qty + amount`。如果只修改一个可用量字段,系统无法区分“已经卖出”与“暂时冻结”。
-- 释放冻结库存
UPDATE inventory
SET available_qty = available_qty + :amount,
frozen_qty = frozen_qty - :amount,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = :sku_id
AND frozen_qty >= :amount
AND status IN ('AVAILABLE', 'FROZEN');释放动作必须有独立请求号,例如 `RELEASE-ORD-10001`,不能复用原来的冻结请求号。因为幂等的对象是“业务动作”,而不是“业务订单”本身。一个订单可以先冻结一次、确认一次、释放一次,但每一种动作都只能成功一次。
在一次实际评审中,我不会只看接口平均耗时,而会把扣减链路拆成数据库连接等待、幂等记录写入、主表更新、流水写入、事务提交和消息投递几个区段。平均值很容易掩盖热点资源,P95 或 P99 才能暴露同一行被大量请求争抢时的排队情况。
下面的观察数据是一个情景模拟样本,用于展示如何设计监控口径,不是某个企业的线上实测。假设 10 分钟内收到 12000 次扣减请求,其中 400 次为重复请求,1000 次因库存不足失败,剩余请求进入正常处理。
| 观察指标 | 示意结果 | 应该追问的问题 |
|---|---|---|
| 重复请求比例 | 3.3% | 是客户端重试、消息重复,还是上游重复生成订单? |
| 库存不足比例 | 8.3% | 是正常售罄,还是库存同步延迟导致的错误判断? |
| 条件更新失败比例 | 9.1% | 失败是否集中在少数热点 SKU? |
| 数据库事务 P95 | 46 毫秒 | 等待来自锁竞争、索引扫描还是流水表写入? |
| 补偿队列数量 | 15 次 | 是否存在提交成功但消息未发送的情况? |

如果业务是单库、单 SKU、扣减动作短、资源热点不明显,我建议优先采用“主表加条件更新加流水唯一键”的组合。此时不必一开始就引入分布式锁、消息队列或复杂分片。
这类方案的重点不是追求最复杂,而是让每个异常都能被解释。对于普通库存,正确的单行更新往往比增加一套外部锁服务更可靠。
余额扣减的错误成本通常高于普通库存。库存可以补货,账务金额一旦发生错误,往往涉及退款、客服、财务和监管。因此余额主表应使用精确数值类型或最小货币单位整数,流水必须记录账户、币种、业务单号、变更方向和操作结果。
余额扣减不应只依赖“当前余额减去金额”这一条语句。还要考虑并发充值、退款、冻结、解冻和跨币种交易。账户余额主表适合快速读取,但最终对账应以经过定义的账务流水为依据,并建立周期性核对机制。
优惠券领取场景的核心风险不一定是库存负数,而是同一用户重复领取。此时应把用户、活动、券类型和周期等业务维度设计成唯一约束。例如“同一用户在同一活动周期只能领取一次”,唯一键就应覆盖用户标识、活动标识和周期标识。
如果活动库存有限,还需要额外的活动库存表或券实例表。用户领取记录与库存扣减应在同一事务内完成,或者通过事件幂等与补偿保持最终一致。不能因为领取记录有唯一键,就认为活动库存也自然不会超发。
短信条数、接口调用次数、团队成员额度等资源通常按日、周或月重置。表结构如果只用用户 ID 作为主键,就可能在新周期开始时与旧周期数据发生覆盖或重置竞争。
更稳妥的模型是把资源主体和周期组合起来,例如 `account_id + quota_period`。扣减语句同时限制周期状态,重置动作也应具有独立的幂等标识,避免定时任务重复执行造成额度凭空增加。
当库存、订单、支付和仓储分别位于不同服务时,不要把“所有服务同时提交”作为唯一目标。更实际的目标是:每一步都有明确状态,每个事件都有唯一编号,每次失败都有重试上限,每个异常都能进入补偿或对账。

资源主表的扣减通常按资源主键定位,主键或唯一索引应当能够快速找到目标行。流水表的查询则可能按请求号、订单号、资源 ID 和时间范围进行,索引需要服务真实的查询与对账任务。
我在评审索引时会同时看三件事:更新语句是否命中正确索引,流水写入是否被过多二级索引拖慢,归档查询是否会扫描历史大表。一个只考虑查询、不考虑写入和归档的索引设计,可能让扣减事务在高峰期变长,反过来放大锁竞争。
事务中应放入必须一起成功或一起失败的数据库操作,例如主表扣减和扣减流水写入。商品详情查询、远程库存查询、发送外部 HTTP 请求和复杂报表计算,不应放在持锁事务内。
但如果为了追求短事务,把主表更新提交后才写流水,就会产生“数量已经减少,但没有操作事实”的窗口。正确做法不是简单拆开,而是先判断哪些操作属于同一一致性边界,再在边界内尽量减少无关动作。
重试可以提高短暂数据库异常下的成功率,却也可能把原本的锁竞争放大。尤其是热点 SKU,如果 500 个请求同时失败并立即重试,数据库看到的不是恢复,而是第二轮更密集的冲击。
把一个热点资源拆成多个库存桶,可以降低所有请求竞争同一行的概率,但系统需要计算多个桶的可用总量。若一条请求跨多个桶扣减,就会出现多行事务;如果采用随机或轮询选桶,还要处理某个桶不足、部分桶成功和失败回滚。
预分配则是把总资源提前分给不同节点或业务分区。它可以减少中心热点,却可能造成局部资源闲置:A 分区没有请求,B 分区已经售罄,但 A 的剩余资源不能及时转移。是否采用,要看资源是否允许短暂的分配不均,以及系统是否具备再平衡能力。

只测试“库存足够时扣减成功”没有太大价值,因为最容易出问题的往往是边界和重试。上线前应使用固定请求号、随机请求号、不同数量和不同资源热点组合,覆盖以下场景:
每个测试场景都应该定义预期结果,而不是只看接口是否返回 200。例如同一请求重复提交 100 次,预期有效扣减次数应为 1;库存不足请求预期不会产生负数;连接断开后重试预期先查询原请求状态,不应直接再扣减。
平均耗时只能说明大多数请求的体验,无法说明热点资源的极端情况。建议至少记录 P50、P95 和 P99 延迟,同时记录锁等待时间、事务持续时间、数据库连接池占用和唯一键冲突次数。
如果 P50 只有 10 毫秒而 P99 达到 2 秒,通常意味着少量热点资源或异常事务正在拖慢系统。此时直接提高线程数可能进一步增加数据库压力,应该先定位是单行竞争、索引扫描、长事务还是外部调用进入事务。

没有对账规则,流水表只是“看起来很完整的日志”。库存对账至少要明确:主表可用量如何计算,哪些操作类型参与汇总,处理中记录如何处理,人工调整是否需要独立审批,以及差异出现后由谁负责恢复。
一种常见的对账思路是以某个时间点的期初数量为基准,累加所有成功的增加和减少动作,再与主表当前值比较。对于冻结模型,还需要同时核对可用量、冻结量和已消耗量之间的关系。对账任务不一定每秒运行,但必须有明确周期、差异阈值和告警责任人。
| 监控指标 | 异常含义 | 优先排查位置 |
|---|---|---|
| 条件更新失败率 | 库存不足、状态不允许或并发冲突增加 | 资源状态、热点分布、业务错误码 |
| 唯一键冲突数 | 重复请求或请求号生成异常 | 客户端重试、消息投递、幂等键规则 |
| 锁等待 P95 | 热点行或事务过长 | SQL、索引、事务边界、资源集中度 |
| 处理中流水超时数 | 事务中断、消费者异常或状态未闭环 | 补偿队列、连接状态、状态迁移逻辑 |
| 主表与流水差异数 | 数据事实无法解释当前状态 | 提交边界、人工修正、对账算法 |

| 方案 | 优势 | 短板 | 适用场景 |
|---|---|---|---|
| 条件更新 | 语句直接、事务短、容易按受影响行数判断 | 复杂业务规则表达有限 | 单资源直接扣减、库存、简单配额 |
| 悲观锁 | 适合读取后多步判断,逻辑直观 | 锁等待明显,长事务风险高 | 复杂读改写、资源数量不多但规则复杂 |
如果扣减逻辑可以压缩成一条带条件的 UPDATE,我通常优先选择条件更新。如果业务必须读取多行并根据读取结果决定后续动作,悲观锁才更有理由出现。但无论选择哪种方式,都要让事务只包含必要数据库操作。
条件更新关注“数量是否足够”;乐观锁关注“这条记录是否仍是我读取时的版本”。两者可以单独使用,也可以组合使用。组合后能同时阻止数量越界和并发覆盖,但失败原因更多,应用层必须区分数量不足与版本冲突。
如果业务失败后不能随便重试,例如资金扣款,版本冲突可以直接转人工或待处理;如果业务允许短暂重试,例如配置额度更新,可以在重读版本后有限重试。架构师需要根据失败成本设计策略,而不是默认所有冲突都自动重试。
同步扣减能够在接口返回时给出明确结果,适合用户必须立即知道库存或额度是否成功的场景。它的代价是请求线程和数据库连接会承受高峰压力,热点资源容易形成排队。
异步扣减可以削峰,但用户拿到的通常是“受理成功”而非“扣减完成”。这要求业务接受处理中状态,并建立事件幂等、超时处理、失败补偿和查询接口。对于资金类业务,异步并不意味着可以降低审计要求,反而需要更完整的状态追踪。
主表加流水适合高频读取当前余额的系统,主表能快速返回可用量,流水用于审计和对账。纯流水账本可以保留更完整的不可变事实,但每次查询当前余额都需要汇总或维护投影,读取成本和工程复杂度更高。
如果系统每天只有少量账户操作,纯流水模型可能足够;如果资源读取频率很高,通常需要维护当前状态投影。无论采用哪一种,必须明确哪个字段是业务读模型,哪个记录是审计事实,以及两者出现差异时如何修复。

如果团队无法用表结构、SQL、事务边界和监控指标回答这些问题,说明方案还停留在“正常路径设计”。真正的架构设计必须先把异常路径写出来,再判断是否需要引入更复杂的技术组件。
不要直接从建表语句开始。先把资源从可用到冻结、确认、释放、撤销和人工修正的状态变化画出来,并在每条箭头上标出触发者、业务请求号和允许重复的次数。这个步骤能提前暴露大量“取消可以重复调用”“确认和释放都能执行”的语义冲突。
明确哪些字段代表当前状态,哪些记录代表不可变业务事实。然后为每一种会改变资源的动作定义唯一标识,并将唯一性落实到数据库约束中。不要只在接口文档里写“接口幂等”,却没有字段和索引承载这个承诺。
至少准备一份可重复运行的测试脚本:固定初始库存,启动多个并发请求,随机注入超时和连接异常,重复提交部分请求,再检查主表、流水和业务单据是否能够对账。测试结果要保留数据库版本、索引、并发数、事务隔离级别和硬件环境,避免把一次偶然结果当成普遍结论。
如果条件更新已经满足正确性目标,且锁等待和尾延迟在可接受范围内,就不必为了“架构看起来高级”而增加分布式锁或异步分桶。如果热点已经造成明显排队,再根据资源集中度、业务可接受延迟和最终一致性边界选择分桶、预分配或消息化。
我对扣减一致性的最终判断是:数据库不是只负责存一个余额数字,而是负责保存当前状态、约束非法变化、识别重复动作,并为异常恢复提供事实依据。表结构设计得好,应用层的判断、重试和补偿会变得简单;表结构设计得差,任何锁和中间件都只能不断填补数据模型留下的缺口。
下一步可以从一张真实资源表开始,逐字段检查可用量、冻结量、版本号、状态、请求号和更新时间,再把主表更新、流水写入、唯一约束和异常回滚放到同一张评审清单中。先证明单库单资源场景正确,再根据压测数据决定是否扩展到分桶、异步和跨服务协调,这通常比一开始追求复杂架构更快,也更稳。


读者评论
文章把扣减一致性拆成数值、请求、状态和事实四个层次,比较贴近实际排查问题的过程。尤其是强调流水表和幂等约束,说明主表数值正确并不等于业务真正正确。
条件更新确实比“先查询再更新”更能缩小并发竞争窗口,但文中也说明了它不能解决跨服务消息失败和多商品订单回滚,这个边界讲得比较客观。
主表保存当前状态、流水表记录业务事实的设计很实用,便于对账和审计。不过流水表字段、索引以及数据量增长后的归档策略,实际落地时还需要进一步细化。
用唯一索引兜底幂等比单纯依赖应用层判断更可靠,特别适合处理接口超时后的重复提交。需要注意的是,重复请求返回什么结果,也应在业务接口层定义清楚。
文章对检查约束、受影响行数和状态判断都有提醒,适合做数据库设计评审参考。但不同数据库对约束和并发行为的实现存在差异,正式上线前仍应结合具体环境压测验证。