库存扣减系统最容易被误判的地方,是大家往往先讨论“要不要加锁、要不要上缓存”,却没有先确认一个更基础的问题:这次扣减究竟要保证什么。在线上项目评审中,我见过接口平均响应时间只有几十毫秒,但在超时重试、消息重复消费和订单创建失败之后,库存账仍然对不上。性能优化解决的是“系统能不能及时处理请求”,扣减一致性解决的是“系统最后有没有扣对”,两者必须放在同一条业务链路里验证。
数据库存:项目经理场景拆解:性能优化如何做到保证扣减一致性
项目经理在评审库存、余额、名额、优惠券或资源配额系统时,最容易接受一句模糊承诺:“我们用事务和行锁,能够保证一致性。”这句话并不完整。数据库事务可以保证一个数据库事务边界内的原子性,但它不能自动处理客户端重复提交、网关超时重试、订单服务失败、消息重复消费、缓存失效以及后续回补失败。
我通常会把“扣减一致性”拆成六个可以验收的问题,而不是把它笼统地写进需求文档:
如果这六个问题没有逐项回答,单独讨论数据库锁类型,往往只是把方案评审提前进入了实现细节。
扣减接口慢,通常不是因为某条 SQL 单独执行太慢,而是因为大量请求同时争抢同一条热点记录。比如某个活动只剩 100 个名额,却在几秒内收到数万次请求。所有请求都访问同一个商品库存行,数据库需要处理锁等待、事务排队、连接占用和失败重试,最终形成级联放大。
因此,性能优化不应简单理解为“把数据库换成缓存”。更有效的思路是拆开争抢路径:哪些请求必须进入强一致扣减区,哪些请求可以被限流或排队,哪些数据可以异步更新,哪些结果必须实时返回,哪些状态可以延迟展示。

我在项目验收中不会只看 QPS。至少要把指标分成性能、一致性和可恢复性三组。性能指标回答系统处理得多快,一致性指标回答系统有没有扣错,可恢复性指标回答出现故障后能不能把账修回来。
| 指标类别 | 建议关注的指标 | 项目经理要追问的问题 |
|---|---|---|
| 性能 | P95、P99、吞吐量、锁等待、数据库连接数 | 高峰期最慢的请求是否已经超过业务可接受范围? |
| 一致性 | 超卖数、重复扣减数、漏扣数、订单库存差异数 | 压测结束后,资源账和业务账是否能够对上? |
| 可恢复性 | 补偿成功率、消息积压时长、对账耗时、人工介入次数 | 数据库或消息链路异常后,系统能否自动恢复? |
我的判断标准是:只要性能提升伴随着差异无法发现,或者差异发现后无法修复,这种优化就不能算项目成功。
商品库存的核心风险通常是超卖、少卖和回补不及时。账户余额的核心风险则是资金多扣、少扣和账务不可追溯。优惠券数量和活动名额可能允许短暂延迟,但发放资格通常不能重复。把这些对象都称为“库存”,再使用同一套技术方案,后期一定会遇到边界问题。
| 扣减对象 | 最不能接受的结果 | 通常能否接受短暂延迟 | 重点设计 |
|---|---|---|---|
| 商品库存 | 负库存、超卖、订单无法履约 | 展示库存通常可以,实际扣减通常不可以 | 原子扣减、幂等、订单失败回补、对账 |
| 账户余额 | 重复扣款、账务无法追溯 | 一般不应接受核心账务延迟 | 流水号、事务边界、状态机、审计记录 |
| 优惠券 | 同一张券被多人使用 | 数量展示可以短暂延迟 | 券码唯一性、使用状态、幂等更新 |
| 活动名额 | 超过上限、资格重复发放 | 排队状态可以延迟 | 资格校验、名额占用、超时释放 |
| 计算资源配额 | 资源透支、任务调度失控 | 部分监控数据可以延迟 | 配额预占、任务状态、释放和补偿 |
项目讨论中经常出现“必须强一致”的要求,但业务方真正想表达的,可能只是“不允许最终超卖”。这两者不同。实时强一致意味着每个时刻的查询结果都要准确,往往需要更高的锁竞争成本;最终正确则允许展示或异步状态短暂滞后,但必须保证对账后结果准确。
例如,活动页面展示“剩余 20 件”时,可以允许缓存延迟几秒;但真正创建订单时,必须再次经过可靠的扣减判断。反过来,如果把缓存中的展示值直接当作最终库存依据,问题就从“页面显示不准”升级成了“业务账扣错”。
我建议项目经理在需求评审时直接写出以下内容:扣减成功的定义是什么,订单创建失败时是否自动回补,回补允许延迟多久,重复请求如何识别,资源不足时是否排队,缓存展示允许多长时间不准确,以及什么情况必须人工介入。
这些问题越早明确,研发越容易选择合适的技术;如果一直等到压测或上线前才讨论,团队通常只能靠临时加锁、增加重试或扩大数据库规格来补救,成本更高,效果却未必稳定。

下面这种写法看起来很直观:先查询可用库存,如果库存大于购买数量,再执行扣减。但在并发场景下,两个请求可能同时读到同一个库存值,随后都通过判断,最终造成超卖。
SELECT available FROM inventory WHERE item_id = :item_id; -- 应用层判断 available 是否足够 UPDATE inventory SET available = available - :quantity WHERE item_id = :item_id;
问题不在于查询本身,而在于“查询判断”和“扣减动作”之间存在时间窗口。项目经理不需要记住所有数据库隔离级别,但必须追问:库存充足的判断是否和扣减放在同一个不可分割的数据库动作中?
更稳妥的做法,是把数量判断写进更新条件,并通过受影响行数判断是否成功:
UPDATE inventory SET available = available - :quantity, updated_at = CURRENT_TIMESTAMP WHERE item_id = :item_id AND available >= :quantity;
如果返回受影响行数为 1,说明这次扣减已执行;如果返回 0,可能是库存不足,也可能是资源记录不存在。实际项目中还要通过查询或错误码区分这两类结果,不能把所有失败都返回成“库存不足”。
行锁主要解决并发访问同一行时的互斥问题,却不能识别“这是同一个业务请求的再次到达”。网络超时是常见诱因:服务端可能已经扣减成功,但响应在返回途中丢失,客户端看到超时后再次提交。如果接口没有幂等控制,第二次请求会被当成新的购买行为。
幂等键不能简单使用用户编号或商品编号,因为同一个用户可能合法购买多次。更合理的做法是由业务方生成唯一请求号,或者使用订单号、支付流水号、业务单号等可以唯一标识一次扣减意图的字段。
常见的幂等记录至少需要保存业务请求号、扣减对象、扣减数量、处理状态、结果和创建时间。再次收到相同请求号时,系统应返回原处理结果,而不是重新执行扣减。
为了“保证一致性”,有些方案会把扣库存、创建订单、调用营销服务、发送通知全部放在一个数据库事务中。这样做的短期效果是流程看起来很完整,但事务持续时间会被远程调用、网络波动和外部服务响应拖长,热点行锁也会被长时间占用。
一旦高峰期出现大量长事务,数据库连接池会先被占满,随后请求超时,客户端和网关开始重试,最终形成重试风暴。此时看起来是业务流量增长,实际放大的却是锁等待和失败重试。
我的判断是:事务应该尽量短,只覆盖必须原子完成的本地数据库动作;跨服务动作应通过状态、事件、幂等和补偿衔接,而不是把所有系统强行塞进一个事务。
缓存适合削峰和承接高频访问,但缓存中的数字不是天然可信的业务账。如果缓存预扣减成功,随后数据库写入失败,系统必须知道这次预扣减处于什么状态;如果服务在中间重启,还要能找到未完成记录;如果消息重复投递,还要保证数据库不会重复扣减。
缓存方案的真正成本,不是增加一个缓存组件,而是增加了状态同步、失败恢复、数据对账和运维监控。项目经理在评审时,应把这些成本纳入方案,而不能只比较缓存和数据库的单次访问速度。

这是所有方案判断的起点。若业务明确要求绝不超卖,那么实际扣减必须经过可验证的原子控制,不能只依赖前端展示、缓存剩余量或异步通知。若业务允许少量延迟,但最终必须正确,则可以考虑预占、排队和异步落库,但必须配置对账和补偿。
“不允许超卖”也要继续追问边界。例如,系统库存是仓库可履约库存,还是营销页面的虚拟库存?如果还有人工审核、供应商确认或门店调拨,数据库中的扣减成功并不等于订单最终可履约,后续还需要预占和释放状态。
总请求量高,不一定代表库存扣减冲突高;真正影响数据库的是请求是否集中到同一个资源。1 万个请求平均访问 1 万个商品,和 1 万个请求争抢同一个热门商品,对数据库的压力完全不同。
我会要求研发提供至少四个观察维度:热门资源的请求集中度、同一资源的并发写入数、扣减失败后的重试比例,以及数据库行锁等待时间。没有这些数据时,直接决定使用缓存、分片或分布式锁,属于凭感觉做架构。
乐观锁、条件更新和消息消费都可能失败,但失败后的处理方式不同。如果一次扣减失败可以快速重试,且业务请求有唯一幂等键,乐观控制可能更合适。如果失败意味着用户必须重新下单,或者重复尝试会引发资金风险,就不能只把失败交给无限重试。
重试必须有上限、退避时间和终止状态。没有上限的重试会把偶发故障放大成系统性故障;没有终止状态的消息会一直积压;没有人工处理入口的异常记录,最终只能通过数据库脚本临时修复。
一个成熟的扣减系统通常不是全链路强一致,而是把最关键的动作放进强一致区,把允许延迟的动作放进最终一致区。以商品购买为例,库存有效性判断和扣减属于强一致区;推荐、埋点、通知和部分页面展示可以属于最终一致区。
强一致区越大,锁竞争和事务成本越高;最终一致区越大,补偿、重放和对账复杂度越高。项目经理要做的不是要求所有环节都“马上一致”,而是推动团队明确哪些环节必须立刻准确,哪些环节可以稍后修正。

缓存、数据库、订单服务和消息系统可能同时保存库存相关状态,但系统必须明确谁拥有最终解释权。通常,数据库中的正式库存账或可审计的资源流水是最终依据;缓存负责加速读取或承接预扣减压力;消息负责传递状态变化,而不是天然成为最终账本。
如果业务决定以缓存为扣减入口,也要明确缓存中的扣减记录如何形成可追溯流水,如何在数据库异常后重放,如何检测缓存丢失,以及如何处理缓存和正式账之间的差异。否则只是把数据库热点转移成了缓存状态风险。
最值得优先评估的方案,往往是最朴素的数据库条件更新。它把“库存是否足够”和“库存减去数量”放在一条更新语句中,让数据库完成原子判断。对于并发量可控、资源热点不极端、业务要求较强一致的系统,这种方案的可维护性通常优于引入多个中间组件。
UPDATE inventory SET available_quantity = available_quantity - :quantity, reserved_quantity = reserved_quantity + :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE item_id = :item_id AND available_quantity >= :quantity AND status = 'ACTIVE';
项目验收时需要检查四个细节。第一,扣减条件是否包含可用数量判断;第二,是否通过受影响行数判断成功与否;第三,商品编号或资源编号是否有合适索引;第四,事务内是否混入了远程调用和不必要的查询。
这个方案的优点是链路短、账务清晰、问题定位相对简单。缺点是热点行竞争达到一定程度后,数据库会出现锁等待,且单条资源记录会成为吞吐上限。因此,不能把它当作所有高并发场景的终点。
乐观锁可以通过版本号或更新时间条件避免并发覆盖。例如,应用先读取版本号为 12 的库存,再执行带版本条件的更新。如果更新时版本已经变化,说明其他请求抢先修改过,当前请求需要失败、重试或返回资源不足。
UPDATE inventory SET available_quantity = available_quantity - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE item_id = :item_id AND version = :version AND available_quantity >= :quantity;
乐观锁并不意味着没有冲突,而是把冲突从数据库锁等待转化为应用层失败处理。低冲突场景下,它可以减少长时间等待;高冲突场景下,大量请求反复读取、更新、失败和重试,反而会增加数据库压力。
项目经理需要重点确认:最多重试几次,重试是否采用随机退避,失败是否会触发用户提示,重试过程中是否保持同一个幂等键,以及高冲突下是否有降级或排队策略。
悲观锁通过显式锁定记录,让同一时间只有一个事务修改目标资源。它的优点是行为直观,适合资源数量有限、并发冲突明显且不能接受并发覆盖的场景。但锁的范围越大、事务持续时间越长,系统吞吐就越容易受到影响。
最常见的错误,是在持有数据库锁期间调用外部服务、等待用户支付或执行复杂计算。数据库事务应该只完成必要的本地状态变更,远程调用应放在事务之外,并通过明确的状态机和补偿逻辑衔接。
如果必须使用悲观锁,建议在验收中加入锁等待、死锁次数、事务持续时间、连接池使用率和超时回滚率。只看“没有超卖”是不够的,因为系统可能是通过把所有请求堵死来实现“不超卖”。
缓存预扣减的核心价值是把热点争抢从关系数据库前移到更适合高并发处理的组件中。请求先完成资格校验和预扣减,再通过消息或异步任务把结果落到正式库存账。这样可以降低数据库瞬时写压力,但也引入了预扣减成功、正式落库失败、消息重复和服务重启等新问题。
一个可落地的预扣减链路,至少要包括以下状态:请求已接收、预扣减成功、等待落库、正式扣减成功、订单创建成功、订单失败待回补、回补成功和人工介入。状态不能只靠一个布尔字段表达,否则出现异常时很难判断系统到底走到了哪一步。
缓存预扣减不适合所有项目。若日常流量不高、热点不明显,却为了“架构先进”引入缓存、消息、补偿和对账,系统的故障面会显著扩大。只有当数据库热点已经被压测或线上监控证实,缓存预扣减才值得承担额外复杂度。

接口地址相同,不代表业务请求相同;用户编号相同,也不代表用户只允许执行一次。幂等键必须绑定一次明确的业务意图,例如某笔订单对某个商品的某次扣减,或者某个支付流水对应的一次余额变动。
数据库中可以建立业务唯一约束,防止同一幂等键重复写入。应用层则需要先查询或尝试插入幂等记录,再决定是否执行扣减。无论采用哪种方式,都要考虑并发下两个请求同时创建幂等记录的情况,不能只在代码中写一个“先判断再插入”。
INSERT INTO deduction_request
(request_id, resource_id, quantity, status, created_at)
VALUES
(:request_id, :resource_id, :quantity, 'PROCESSING', CURRENT_TIMESTAMP);— 业务唯一约束:request_id 不允许重复
— 插入成功:继续扣减
— 插入冲突:读取原请求状态并返回原处理结果
扣减过程通常不会只有成功和失败两种状态。请求可能已经进入处理、资源已经预占、订单尚未创建、消息等待消费或回补正在执行。如果只保留一个成功标记,服务重启后就无法判断未完成动作,也无法决定应该继续、回滚还是人工确认。
| 状态 | 含义 | 允许的下一状态 | 处理重点 |
|---|---|---|---|
| 已接收 | 系统已经记录业务请求 | 处理中、已拒绝 | 避免请求还未落记录就发生重复处理 |
| 预占成功 | 资源暂时被当前请求占用 | 已确认、待回补 | 设置超时时间,避免资源长期冻结 |
| 正式扣减成功 | 正式库存账已经变化 | 订单成功、待回补 | 记录扣减流水并可被对账 |
| 待回补 | 后续业务失败,需要释放资源 | 回补成功、人工介入 | 限制重试次数并保留失败原因 |
| 人工介入 | 自动流程无法安全判断 | 修复完成、关闭 | 必须有操作记录和权限控制 |
消息系统解决的是解耦和削峰,不是自动保证业务一致。消息可能重复、延迟、乱序或进入死信队列,因此消费方必须具备幂等能力。对于每条消息,建议记录消息编号、业务请求号、消费状态、重试次数、最后错误和处理时间。
如果消息代表“扣减库存”,消费方需要检查该业务请求是否已经成功处理;如果消息代表“回补库存”,则要检查是否已经完成回补。不能因为消息内容相同,就认为重复消费一定是安全的;必须有业务状态约束。
很多项目把补偿实现成一个每隔几分钟执行一次的脚本,但没有定义哪些记录需要补、补偿是否幂等、补偿失败怎么办、补偿完成后如何验证。这样的脚本在数据量较小时可能有效,到了高峰期就容易出现重复回补或漏补。
可靠补偿需要有明确的候选条件、处理锁、最大重试次数、错误分类和结果记录。补偿任务完成后,还需要通过对账确认资源账、业务单据账和流水账是否重新一致。否则只是把状态从“异常”改成了“处理完成”,并没有证明数据真的正确。

下面使用一个示例项目说明评审过程。某电商活动准备在 10 分钟内发售 5000 份限量商品,产品要求用户点击后尽快得到结果,运营要求不能超过可履约库存,仓储系统又不能承受每个请求都同步查询。
压测前,团队提出了三个方案。方案 A 是所有请求直接访问数据库做条件更新;方案 B 是数据库加乐观锁并允许失败重试;方案 C 是缓存预扣减、消息落库、订单失败回补。项目经理不能直接按“方案 C 最先进”来拍板,而要先确认高峰请求的集中度、数据库可承受写入量和业务对延迟的容忍范围。
基线压测应尽量模拟真实请求,包括有效请求、库存不足请求、重复请求和接口超时重试。只发送大量互不相同的商品请求,会低估热点行锁竞争;只测试接口成功率,又会忽略订单失败和回补链路。
在示例推演中,单数据库条件更新方案在分散流量下表现稳定,但当 70% 的请求集中到一个热门资源时,P99 响应时间明显上升,锁等待和连接池使用率同时增加。这个结果并不说明数据库方案错误,而是说明该方案已经遇到热点集中这一边界。
| 压测场景 | 请求分布 | 主要观察结果 | 评审结论 |
|---|---|---|---|
| 分散访问 | 10000 个资源平均分布 | 锁等待低,数据库写入平稳 | 优先采用数据库条件更新 |
| 中度热点 | 30% 请求集中到 10 个资源 | 尾延迟升高,失败重试开始增加 | 增加限流、幂等和重试退避 |
| 极端热点 | 70% 请求集中到 1 个资源 | 热点锁等待明显,数据库连接占用升高 | 评估排队、分段库存或预扣减 |
| 故障重试 | 10% 请求模拟超时后重复提交 | 无幂等时出现重复扣减风险 | 幂等必须先于性能优化上线 |
在这个示例中,5000 份商品的库存数量并不大,真正的问题是高峰时请求集中到同一资源。若运营可以接受排队结果,优先考虑限流和排队,可能比引入完整缓存预扣减链路更容易控制。
如果活动必须在极短时间内承接大量请求,且数据库基线压测已经证明热点写入无法满足要求,才考虑预扣减。此时必须同时设计预扣减流水、落库消息、订单失败回补、消息重复消费、服务重启恢复和对账任务。
在这个案例里,最重要的技术决策不是“数据库还是缓存”,而是把 30000 次到达请求过滤成真正需要争抢 5000 份资源的有效请求。资格校验、重复请求过滤和流量排队,本身就能减少大量无效数据库压力。
可以将链路拆分为以下几个阶段:
同步链路的目标是尽快确认“请求是否获得资源”,异步链路的目标是完成后续状态传播和异常恢复。两者不能混为一谈。用户看到“抢购成功”后,系统仍然需要确保订单状态、库存状态和流水状态能够最终对齐。

示例项目至少要演练五类异常:扣减成功但订单服务超时、消息投递成功但消费方重复消费、数据库写入后服务立即重启、缓存预扣减后落库失败,以及回补任务执行中断。
每类异常都要明确预期结果。例如,订单服务超时不能直接判定库存扣减失败;如果扣减状态已经落库,后续应通过订单查询、消息或补偿确认。服务重启后,系统应能根据处理中状态找到未完成请求,而不是让用户重新提交并产生第二次扣减。
平均响应时间会把大量快速失败请求和少量极慢请求平均掉。扣减场景中,真正影响用户体验和系统稳定性的,通常是 P95、P99、超时率和重试率。尤其是热点资源争抢时,前 90% 请求可能很快,最后 10% 请求却长时间等待数据库锁。
在项目评审里,我会要求报告同时展示平均值、P95、P99、最大值和超时请求数。如果报告只有平均耗时和吞吐量,没有锁等待、连接池和重试数据,那么这份压测结果还不足以支持上线决策。
库存不足导致的失败,是正常业务结果;数据库超时、消息积压和服务异常导致的失败,是系统问题。两者如果混在一个失败率里,团队就无法判断是资源卖完了,还是系统处理能力不足。
| 失败类型 | 示例 | 是否需要重试 | 是否需要告警 |
|---|---|---|---|
| 业务失败 | 库存不足、资格不符、超过购买上限 | 通常不需要自动重试 | 关注比例异常,不一定逐条告警 |
| 临时系统失败 | 数据库连接短暂不可用、消息发送超时 | 可有限重试并退避 | 需要监控趋势和持续时间 |
| 状态不确定 | 请求超时但服务端可能已成功 | 必须使用幂等键查询或重试 | 需要追踪未决请求 |
| 数据异常 | 扣减流水与订单状态无法匹配 | 不能盲目自动重试 | 需要高优先级告警和补偿流程 |
接口返回成功,只能说明某个请求在某个时间点得到了成功响应;它不能证明库存账、订单账和流水账最终一致。对账应至少比较资源初始量、有效扣减量、有效回补量和当前可用量,并将每笔差异关联到业务请求号。
在库存业务中,可以使用以下基本关系进行校验:
期末可用库存
= 期初库存
有效扣减总量
+ 有效回补总量
其他已确认出库量
实际系统可能还包含锁定库存、调拨库存、报损库存和人工修正,因此公式需要结合业务定义扩展。关键是每个数量都必须有来源、有状态、有时间范围,不能用一个最终库存字段掩盖中间过程。

如果日常并发量不高,资源访问较分散,数据库锁等待处于可接受范围,优先选择条件更新、短事务和幂等记录。此时没有必要为了追求架构复杂度而引入缓存预扣减和消息编排。
项目经理的重点应放在索引、事务范围、错误码、幂等键、回补入口和对账任务上。一个链路短、能被团队理解和维护的方案,往往比组件更多但边界模糊的方案更可靠。
如果同一资源存在一定并发冲突,但冲突请求可以接受失败或短暂重试,可以评估版本号、条件更新和退避机制。这里必须建立重试上限,例如重试两到三次后进入明确失败状态,而不是让请求持续占用连接。
项目验收应观察重试后的总 SQL 次数、锁等待变化、失败请求的最终处理结果,以及重试是否造成新的流量尖峰。若冲突率持续升高,说明乐观控制已经接近边界,需要重新评估限流、排队或资源拆分。
热点场景的第一步通常不是立刻把库存搬到缓存,而是减少无效请求进入扣减区。可以采用活动资格预校验、用户购买次数限制、令牌桶限流、请求排队和分段库存等方式。
如果这些手段仍然无法满足峰值,且数据库热点写入已经通过监控和压测确认成为瓶颈,再引入缓存预扣减。此时必须把消息落库、超时回补、服务重启恢复、消费幂等和对账作为同一个项目交付,而不能分成“主流程先上线、异常能力以后再补”。
跨服务扣减通常无法依靠单个数据库事务保证全链路一致。建议先完成本地资源状态变更,再通过可靠事件推动订单创建或后续业务动作。每个消费者都要能够重复处理,每个关键状态都要能查询,每个失败状态都要有补偿路径。
项目经理需要明确事件的生产方、消费方、重试规则、死信处理人和对账责任人。否则技术上虽然使用了消息,但业务上没有形成完整闭环,最终仍然会依赖人工查日志。
余额、资金、积分等涉及账务的扣减,通常不应为了追求极致吞吐而牺牲可追溯性。必须有唯一流水号、不可随意覆盖的状态变化、完整审计字段和明确的冲正机制。
如果业务确实存在极端峰值,应优先从排队、分区账本、预授权和异步通知等方向优化,而不是直接让缓存成为唯一资金依据。对资金数据而言,系统慢可以排队,账务错则会影响信任和后续对账。

数据库原子更新的最大优势,是所有关键变化都在一个正式数据源中完成,问题定位和对账相对直接。它的限制也很清楚:热点集中时,单行写入会成为瓶颈;如果业务跨越多个服务,单库事务无法覆盖完整链路。
乐观锁适合“冲突偶尔发生,失败后可以重新尝试”的业务。它减少了长时间持锁,但把一部分成本转移到了应用层。冲突越高,重试越频繁,数据库读写次数越多,最终可能比直接排队更差。
悲观锁的优点是行为可预测,强制串行化能够直接避免并发覆盖。但它会把热点资源的处理能力绑定到单行锁和事务持续时间上。若团队缺少锁等待监控和死锁处理经验,问题通常会在高峰期集中暴露。
缓存预扣减可以显著降低数据库入口压力,但它把一致性问题从一个数据库事务扩展成一条分布式状态链路。系统需要承担缓存故障、异步落库失败、消息重复、状态丢失、回补和对账等长期成本。

功能测试不能只验证“库存足够时扣减成功”。至少要覆盖库存不足、购买数量为零、数量超限、同一请求重复提交、不同请求并发提交、扣减成功后订单失败、订单取消后回补和回补重复执行。
真实业务的性能验收至少要设计三组流量模型。第一组是分散资源访问,用来观察基础数据库能力;第二组是中度热点访问,用来观察锁竞争和重试;第三组是极端热点访问,用来验证限流、排队或预扣减是否发挥作用。
每组测试都应记录并发用户数、请求速率、有效请求比例、重复请求比例、资源集中度、平均响应时间、P95、P99、数据库 CPU、锁等待、连接池和错误分类。没有测试条件,单独谈“支持多少 QPS”没有可比性。
压测结束后,测试团队应导出业务请求记录、扣减流水、订单状态和资源汇总,按业务请求号和资源编号进行核对。需要重点检查是否存在同一请求多条成功流水、成功扣减没有对应订单、失败订单仍占用资源以及回补数量超过原扣减数量。
如果采用缓存预扣减,还要比较缓存状态和数据库正式账。对于短暂差异,应明确允许时间和自动修复方式;对于超过时限仍未收敛的差异,应触发告警并进入人工处理队列。
扣减系统最危险的不是明确失败,而是状态未知。比如数据库更新可能已经提交,但应用在返回前宕机;消息可能已经发送,但发送结果没有返回;订单可能已经创建,但调用方没有收到响应。
对这类场景,测试不能只要求“接口返回失败”。更重要的是验证系统能否通过幂等查询、状态机、消息重放或补偿任务把未知状态最终归类为成功、失败或人工介入。只要未知状态长期悬挂,库存和订单迟早会出现差异。

技术监控可以看到 CPU、内存、连接池和接口耗时,但项目经理还需要推动建设业务监控。至少应监控扣减成功量、扣减失败量、重复请求拦截量、待回补量、回补失败量、对账差异量和未决请求年龄。
这些指标应该能够按活动、商品、资源编号、业务请求号和时间段筛选。只有能定位到具体业务对象,运营和研发才有机会在资源差异扩大前采取措施。
库存不足失败在活动开始后快速增加,可能只是资源正常售罄;锁等待短时间升高,可能是瞬时流量冲击;但待回补记录持续增长、对账差异超过阈值或未决请求长期不减少,就属于需要立即处理的异常。
告警规则不能只根据单个数值触发,还应结合持续时间、增长速度和业务背景。例如,回补失败率连续 5 分钟上升,比某一分钟出现几条失败记录更值得优先处理。
对账可以按小时、按活动批次或按日执行,具体频率取决于业务风险。高风险资源应提高频率,低风险资源可以按日核对。对账结果需要记录差异数量、差异金额或数量、涉及业务单据和处理状态。
对于已经完成补偿的记录,还要保留原始差异和修复动作。这样既方便复盘,也能避免同一问题被重复修复。审计记录不是为了增加流程,而是为了让团队知道每一次账务变化是谁、在何时、以什么原因完成的。

第一份是业务流程图,标出扣减、订单、支付、回补和通知的先后关系;第二份是数据模型,说明库存表、流水表、幂等表和状态字段如何关联;第三份是压测报告,包含热点分布和异常场景;第四份是故障处理方案,说明服务重启、消息重复和数据库异常后如何恢复。
如果只有架构图,没有数据模型,团队可能没有想清楚如何对账;如果只有 QPS,没有热点分布,团队可能没有测试真正危险的场景;如果只有正常链路,没有故障演练,方案上线后仍然可能依赖人工救火。
这些问题确定后,再讨论使用条件更新、乐观锁、悲观锁、缓存或消息。顺序不能反过来,否则技术团队会围绕熟悉的组件争论,却没有统一业务目标。
一份好的方案评审记录,不仅要写最终选择什么,还要写为什么不选择其他方案。例如,当前采用数据库条件更新,是因为热点集中度较低、事务边界简单、对账要求高;暂不采用缓存预扣减,是因为当前压测没有证明数据库已经成为瓶颈。
记录不选择的方案很有价值。随着业务增长,未来如果热点集中度、锁等待或峰值请求达到某个阈值,团队可以按照原来的边界重新评估,而不是每次都从头争论。
可以先用下面这张表完成第一轮判断:
| 项目条件 | 优先方案 | 必须补充的能力 | 暂时不要做的事 |
|---|---|---|---|
| 流量中等、热点低 | 数据库条件更新 | 幂等、短事务、对账 | 不要为了追求高并发过早引入复杂组件 |
| 冲突中等、失败可重试 | 乐观锁或条件更新 | 重试上限、随机退避、冲突监控 | 不要无限重试或只看平均响应时间 |
| 热点极高、峰值集中 | 限流、排队、分段资源或缓存预扣减 | 状态机、消息幂等、补偿、对账 | 不要把缓存数字直接当成唯一正式账 |
| 涉及资金和强审计 | 正式流水、短事务、明确状态变更 | 审计、冲正、对账、人工复核 | 不要用异步缓存替代正式账本 |
如果项目当前还没有完整方案,不必一次性重构所有链路。我建议按四周推进。第一周定义扣减对象、一致性边界和业务状态;第二周补齐幂等、原子更新和关键流水;第三周进行分散流量、热点流量和故障重试压测;第四周完成对账、补偿和监控上线。
如果压测显示数据库条件更新已经满足目标,就保持方案简单;如果热点锁等待成为明确瓶颈,再评估排队、分段库存或缓存预扣减。技术升级应该由可观察的瓶颈触发,而不是由架构潮流触发。
一个扣减接口可以很快返回,但如果请求重复执行、订单失败不回补、消息重复消费或对账无法完成,这种速度只是把问题更快地制造出来。相反,一个需要排队几十毫秒但能够准确扣减、可追踪、可补偿的系统,往往更适合核心业务。
把所有流程强行放入一个大事务,会让数据库承担不必要的等待和失败风险。更成熟的做法,是把核心扣减动作做成局部强一致,把订单通知、展示更新和非关键扩展动作做成可追踪的最终一致,并用幂等、状态机、补偿和对账把链路闭合。
下一次评审扣减系统时,我建议不要先问“用什么锁”,而是依次问四件事:哪些结果绝对不能发生,热点冲突来自哪里,失败后如何恢复,最后用什么数据证明系统确实扣对了。
当团队能够回答这四个问题,数据库条件更新、乐观锁、悲观锁、缓存预扣减和消息异步化就不再是互相替代的技术名词,而会成为针对不同业务边界的工具。性能优化的终点不是把响应时间压到最低,而是在可接受的成本内,让系统在高峰、重试和故障之后仍然能够给出一笔对得上的账。
我在评审库存接口时,研发一开始采用“先查询可用库存,再执行扣减”的两步逻辑,单元测试全部通过,但并发压测时偶尔出现负库存。我想知道,问题到底出在数据库锁、事务隔离级别,还是代码执行顺序上?
核心问题通常不在“有没有事务”,而在于库存判断和库存扣减是不是同一个原子动作。先查询再更新时,两个请求可能同时读到剩余库存为 1,随后各自执行扣减,事务虽然存在,但如果没有正确的并发控制,两个请求仍可能都认为自己有资格成功。
更稳妥的做法是把业务条件直接写进 UPDATE:
UPDATE inventory SET available = available - :quantity WHERE item_id = :item_id AND available >= :quantity;执行后必须检查受影响行数。返回 1,才表示扣减成功;返回 0,说明库存不足或并发竞争失败。项目经理验收时不要只问“有没有加事务”,而要追问“扣减成功的唯一判定依据是什么”。我在一次压测对比中,使用 100 个并发请求争抢 50 件库存。先查询再更新的实现出现过库存差异,原因是查询结果和更新动作之间存在竞争窗口;
改成条件更新后,库存数量始终没有低于 0,但冲突请求的失败率明显上升。这不是方案变差,而是系统终于把原本隐藏的并发冲突显式暴露出来。
实现方式主要风险项目验收重点 先查询,再扣减存在竞争窗口,可能超卖并发下是否出现负库存 条件更新冲突时请求失败是否正确判断受影响行数 行锁后查询再更新锁等待和事务时长增加锁等待、超时、死锁情况 我的判断是:低并发业务优先使用数据库条件更新,先把“扣对”做好,再讨论缓存和异步化。
性能优化不能通过拆开库存判断和扣减来换取,因为那只是把数据库压力转化成了业务事故。
我负责一个活动项目时,研发提出了三种方案:数据库乐观锁、数据库悲观锁和缓存预扣减。大家都在谈吞吐量,却没有说明各自会把风险转移到哪里,我应该用什么标准做技术选型?
选型不能只看并发量,还要看资源是否集中在少数热点记录、失败后能否重试,以及业务是否允许短暂不一致。并发高但资源分散时,数据库条件更新或乐观锁可能已经足够;并发集中争抢同一条库存记录时,真正的瓶颈往往是热点行,而不是数据库整体性能。乐观锁适合冲突可控、失败后可以快速重试的场景。
例如通过版本号或可用库存条件更新,冲突请求直接失败或有限重试。但高冲突场景下,无限制重试会形成“重试风暴”:第一次扣减失败的请求再次访问数据库,反而进一步放大锁竞争。悲观锁的优势是规则直接,适合不允许并发覆盖且事务很短的场景。
它的危险也很明确:如果事务中包含远程调用、复杂计算或消息发送,锁会被长时间占用,数据库连接池、锁等待和接口尾延迟可能同时恶化。缓存预扣减适合把高峰流量挡在数据库之外,但它不是一致性方案,而是吞吐方案。缓存扣减成功后,数据库写入失败、服务重启、消息重复消费、订单创建失败,都必须有回补和对账机制。
否则系统只是从“数据库超卖”变成了“缓存和数据库账目不一致”。
方案更适合的场景主要代价 条件更新或乐观锁冲突可控、允许失败重试高冲突时重试会放大压力 悲观锁强一致、事务链路短锁等待、死锁和尾延迟 缓存预扣减热点明显、需要削峰落库、回补、对账复杂 项目经理可以用一个反向问题推动评审:如果缓存已经扣成功,但订单服务没有创建成功,谁负责把库存加回来,多久完成,如何证明没有重复回补?
如果这个问题答不清楚,就不应仅因为缓存吞吐量高而直接采用缓存预扣减。
我遇到过一次扣减接口响应超时,用户重新点击后订单最终创建成功,但库存却少扣了一次。研发认为这是网络问题,测试认为应该增加重试,我想知道项目经理应当如何定义这类请求的正确行为?
网络超时不能直接等同于业务失败。请求可能已经在数据库中扣减成功,只是响应没有返回到客户端。如果客户端、网关或消息消费者再次执行同一业务动作,而系统没有幂等控制,就会把一次购买变成两次扣减。幂等键应来自业务动作,而不是简单使用每次 HTTP 请求生成的随机编号。
建议由订单号、活动编号、商品编号和扣减动作版本等信息组成唯一业务请求号,并在服务端建立幂等记录或唯一约束。相同业务请求再次到达时,应返回第一次处理结果,而不是重新执行业务扣减。我在测试这类链路时,会刻意制造三种异常:数据库扣减成功后延迟返回、订单创建成功后接口超时、消息投递成功但消费者确认失败。
很多系统在正常链路下表现良好,但一加上重试就出现重复扣减,说明它只测试了功能正确,没有测试“结果已经发生但调用方不知道”的状态。
异常场景错误做法正确处理方向 扣减成功,响应超时直接再次扣减使用原业务幂等键查询处理结果 消息重复消费每次消费都执行扣减消费记录加唯一约束或状态判断 扣减失败,客户端重试无限快速重试限制次数并采用退避策略 订单取消后回补重复执行加库存回补动作也必须单独幂等 需要特别注意,扣减幂等和回补幂等不是同一个动作。
一次订单可能经历扣减、取消、回补、再次下单等多个状态变化,每个动作都应有独立的业务流水号和状态机。项目验收时,不能只验证“重复提交不重复扣减”,还要验证重复回调、重复取消和重复补偿不会把库存加多。
我曾经遇到过一个接口压测结果很好,平均响应时间下降了很多,但上线后仍然出现库存差异。后来发现压测只关注 QPS 和平均耗时,没有覆盖超时重试、消息重复和服务重启,我想建立一套更可靠的验收标准。
扣减系统的验收不能只证明“跑得快”,还要证明“在失败之后仍然扣得对”。平均响应时间容易掩盖问题,项目经理至少要同时关注 P95、P99、锁等待、超时率、重试率、库存差异数和补偿成功率。可以把验收拆成三组指标。
第一组是正确性指标:库存不能低于零,成功扣减总量应等于有效订单扣减量,重复业务请求不能产生重复流水。第二组是性能指标:观察目标并发下的吞吐量、P95/P99 响应时间、数据库连接使用率和锁等待时间。第三组是恢复指标:数据库短暂不可用、消息重复、服务重启后,系统能否恢复并完成对账。
验收维度建议观察指标不能替代的测试 扣减正确性负库存、重复流水、漏扣数量不能只看接口返回成功 接口性能吞吐量、P95、P99、超时率不能只看平均响应时间 数据库健康度锁等待、连接池、CPU、慢查询不能只看应用服务器负载 异常恢复补偿成功率、对账差异、消息积压不能只做正常链路压测 我建议在项目验收中加入“故障注入”而不是只做并发压测。
例如在数据库扣减成功后人为延迟响应,在订单创建成功后断开网络,在消息确认前重启消费者,再检查库存流水、订单状态和补偿结果。这样的测试更接近线上真实事故,因为线上最危险的不是所有请求都失败,而是系统已经部分成功。
最终应建立一条可追溯的对账链路:业务请求号、库存流水号、订单号、消息编号和补偿编号能够相互关联。性能优化是否合格,不是看某个接口快了多少,而是看高峰结束后账目能否对上、异常能否自动收敛,以及人工是否能快速定位剩余差异。


读者评论
文章把性能和一致性拆开讨论很实用,尤其是“查询判断”和“扣减动作”之间的并发窗口。用带条件的原子更新并结合受影响行数判断,比先查再改更稳妥。
对幂等和超时重试的分析比较到位。行锁只能解决并发互斥,不能防止同一个请求重复扣减,实际项目中确实需要业务请求号和处理结果记录。
项目验收同时关注性能、一致性和可恢复性,这个思路值得借鉴。单看QPS或平均响应时间容易忽略超卖、漏扣以及故障后的补偿能力。
文章对缓存扣减的风险提醒很现实。缓存适合削峰,但不能直接作为最终账本,必须配合状态记录、失败恢复和对账机制,否则缓存与数据库异常时较难追责。