去年双十一,我们团队差点因为一个“售罄商品仍在前端展示”的 bug 丢掉一个大客户。凌晨两点,运营在群里疯狂艾特技术,说一个已经卖光的爆款商品还在活动页挂着,用户点进去就是“下单失败”,客诉量半小时内飙升了300%。事后复盘,问题的根源不是什么高深的并发瓶颈,而是一条被我们集体忽视的接口链路:库存管理系统在商品售罄后,没有主动触发前端展示层的屏蔽指令。前端缓存的那个“立即购买”按钮,就像一面骗人的幌子,挂在那里整整挂了四十分钟。
这件事之后,我花了三个多月时间,把市面上主流的“售罄屏蔽”方案挨个摸了一遍。从最简单的定时轮询,到看似高端的 WebSocket 推送,再到被很多人低估的 Server-Sent Events,我都在自己的测试环境里跑过压测、模拟过断网、人为制造过消息队列积压。结果发现了一个反常识的结论:绝大多数“售罄后前端展示不同步”的事故,问题都不出在“推送技术选型”上,而出在“接口联动的容错设计”上。本篇文章不会复述那些你在官方文档里就能查到的 API 调用方法,而是基于我这几个月真实的踩坑记录,把库存售罄后前端屏蔽这件事,从“怎么连上”推进到“怎么连不断、连不丢、连不错”的层面。
做了这么多年后端开发,我发现一个很有意思的现象:一说起“库存售罄后前端屏蔽”,大部分人的第一反应都是“搞个 WebSocket 推送一下不就行了”。但真要追问一句,推送失败了怎么办?前端根本没收到这条消息怎么办?消息发出去了但前端 JS 报错没执行怎么办,能给出完整方案的人至少少一半。
我的核心判断就一句话:库存售罄后的前端屏蔽,本质上不是一条推送消息的问题,而是一个需要从“事件源”到“展示层”全链路设计的分布式状态同步问题。你设计的不是一个接口,你设计的是一套包含“正常推送 + 异常兜底 + 故障降级 + 恢复补偿”的完整闭合逻辑。下面这张图大概能说明我理解中的“真·接口联动”应该长什么样:

这个判断不是拍脑袋想出来的。我在做方案调研的时候,翻了十几个技术博客和问答帖,发现绝大多数内容都停留在“怎么发一条 WebSocket 消息”这个层级。好像只要把消息发出去了,问题就解决了。但放在真实的生产环境里,尤其是在大促期间几百万人同时在线的情况下,那条消息能不能准时送到每一个客户端,根本就是个概率问题。
别急着跳进代码,先把场景掰清楚。我经手过的“售罄不同步”事故主要有三类,每一类的伤害模式都不一样。
2023 年帮一个做跨境独立站的客户排查问题时遇到的情况。他们用的是 Shopify 的某个第三方库存插件,商品实际库存归零之后,后台系统状态已经更新了,但前端商品列表页(Collection Page)因为 CDN 缓存策略设得太激进,整整八个小时都在展示“Add to Cart”按钮。用户点进去,跳转到商品详情页,那边倒是有实时校验接口,直接返回“Out of Stock”。用户感觉被耍了,投诉率在那个时间段涨了 5 倍。
这个案例的教训是:商品列表页的展示状态,往往比详情页更需要精准的售卖状态同步。因为列表页是用户浏览决策的入口,挂了售罄标志却还能点进去,比直接不展示这件商品更恶心人。

这就是文章开头提到的那次双十一事故。我们当时用的方案是:库存归零 → 后端写库 → 通过 Redis Pub/Sub 发通知 → 前端 WebSocket 收到后隐藏按钮。看起来链路很标准对吧?问题就出在 Redis Pub/Sub 是fire-and-forget的,没有消息持久化机制。当时后端 Redis 实例因为一个不相关的 keys 命令导致 CPU 瞬时飙高,几条关键的推送消息直接被丢弃,没有任何重发机制。
我们后来复盘发现,就在那四十分钟的窗口期里,有 37 个用户成功提交了已售罄商品的订单,全部变成了待退款状态。表面上只是“多卖了”,实际上处理退款的人力成本、支付通道手续费、还有用户体验伤害,远远超过那几十单的销售额。
这个坑更隐蔽。我们在去年 Q3 测试 SSE 方案的时候,遇到过一个很刁钻的情况:后端推送的消息前端确实收到了,但前端 JavaScript 在执行 DOM 隐藏逻辑时,因为商品列表是一个无限滚动组件,目标 DOM 节点还没渲染出来,querySelector 返回了 null,整个隐藏操作静默失败了。页面上没有任何报错,从 Chrome DevTools 的 Network 面板看消息也收到了,但那个售罄商品就是顽固地挂在列表里。
这三类事故告诉了我一个残酷的事实:把“推送消息发出”等同于“前端正确展示售罄状态”,就像把“邮件发出”等同于“对方已读”一样天真。
做完事故复盘之后,我刻意去看了一下团队内部和外部同行对这个问题的理解,发现有三个误区出现频率极高,而且很容易把人带进沟里。
很多人在讨论这个问题的时候,张嘴就是“我们做了库存同步接口”。但你细问下去就会发现,他说的“同步接口”指的是每次用户打开页面时调一次 GET 请求,拉取最新库存状态。这本质上是被动查询,不是主动联动。
真正的接口联动,是指库存管理系统(WMS 或 ERP 的库存模块)作为事件的发起方,在库存状态发生变更的那一刻,主动通知下游系统“我这边变了,你那边要跟着动”。被动查询最大的问题在于“查询间隙”,用户在两次查询之间的空白期看到的一定是旧状态,而秒杀场景下,这个空白期就足以酿成事故。

WebSocket 确实是目前最主流的实时推送方案,但把它当成“万能药”的人往往忽略了三个关键限制:
第一,连接数天花板。 一个标准的服务器实例(比如 4核 8G 的云服务器)能承载的并发 WebSocket 连接数大致在 5 万到 10 万之间,具体取决于你用的框架和优化策略。大促期间如果同时在线用户达到百万级,你得提前规划好水平扩展方案,不然连接池先崩了。
第二,重连风暴。 这是我最怕的场景。假设你的 WebSocket 服务因为某种原因重启了,几万个客户端同时检测到断连,然后在同一秒内发起重连请求。这个瞬时流量冲击足以把你的服务再次打挂,形成“重启-雪崩-再重启”的恶性循环。
第三,消息可靠性为零。 WebSocket 协议本身不保证消息必达。客户端断网、切后台(移动端浏览器会挂起 WebSocket)、代理服务器超时断连,这些情况下服务端发出去的消息就永远丢失了。所以单一依赖 WebSocket 做售罄通知,等于把业务可靠性寄托在网络稳定性上。
这是被低估得最厉害的一个环节。实际上,前端收到了“SKU=12345 已售罄”的消息之后,要完成的事情远比你想的复杂:
这些事情,没有任何一个是在后端发一条消息就能自动完成的。
基于前面踩过的坑和拆过的误区,我给自己整理了一套判断框架,用来评估任何一个“售罄屏蔽方案”到底靠不靠谱。这套框架分成三个层级。
这是最基本的要求,但这里有一个细节很多人会跳过。正常链路不只是“库存变零→发消息→前端收到→隐藏”,而是“库存变零→发消息→前端收到→前端确认隐藏成功→回执给服务端”。
为什么需要回执?因为在分布式系统里,消息发送方如果不收到确认,就永远不知道接收方到底执行成功了没有。这个“确认”不一定是一条反向的 WebSocket 消息或者 HTTP 请求(那样太重了),可以简单到只是一条日志上报或者埋点事件。但你得有一个机制,让你在事后能回答这个问题:某一条售罄通知,到底有多少个客户端真正执行了隐藏操作?
这是区分“能用”和“靠谱”的分水岭。我在设计兜底方案的时候,通常按以下四个层级来排优先级:
第一优先级:客户端本地兜底。 前端在页面初始化时,把当前页面涉及的所有 SKU 收集起来,调用一个批量状态查询接口(/api/inventory/batch-status),拿到结果后存入本地缓存。后续每次渲染商品卡片之前,先过一遍本地缓存,售罄的 SKU 直接不渲染。
第二优先级:定时兜底轮询。 假设 WebSocket 或 SSE 连接断了,前端启动一个定时器,每隔 30 秒(或者根据业务容忍度调成 15 秒或 60 秒)全量拉取一次当前页面所有商品的状态。这不是最优雅的方案,但在推送失效的时候,它是你最可靠的防线。
第三优先级:下单前终极校验。 用户点击“立即购买”或者“加入购物车”的时候,必须走一次实时的库存校验接口。这一步是最后的防线,哪怕前面所有同步都失败了,到了下单这一步也一定要拦住。
第四优先级:后端对账补偿。 这个机制相对低频但非常重要。后端有一个定时任务,每隔一段时间(比如 5 分钟)扫描一次“状态为售罄但仍然收到下单请求”的异常记录,一旦发现就触发人工告警,同时主动向关联的客户端补发一次强制刷新指令。

这一点我几乎没在任何公开的技术博客里看到有人讲过,但它偏偏是我最在意的一个环节。降级和兜底是两回事:兜底是在主链路断了之后启用备选链路,降级是在系统压力过大时主动放弃一部分功能来保住核心链路。
具体到售罄屏蔽这个场景,降级意味着什么?意味着当你的 WebSocket 服务已经扛不住、消息队列也开始积压的时候,你不能再傻傻地继续往里面塞消息。你需要一个熔断开关:当消息队列积压超过一定阈值(比如 5000 条),自动停止发送非关键通知,只保留“已售罄”这一种最高优先级的消息,其他像“库存紧张”“价格变动”之类的低优先级通知全部暂停。同时前端自动切换到纯轮询模式,降低对推送链路的依赖。
我见过最惨的一次事故,就是没有这个熔断机制。推送服务挂了之后,所有消息全部积压在 Kafka 里,重启之后一口气把十几万条过期消息全推了出去,导致前端页面疯狂跳动刷新,用户完全没法正常浏览商品。
下面这部分可能是整篇文章含金量最高的内容。我在自己的测试环境里,用同一套电商模拟系统,分别测试了四种主流的售罄屏蔽方案。测试环境配置是:两台 4核 8G 云服务器做后端,一台跑库存服务和数据库,一台跑推送服务和消息队列。前端模拟 500 个并发用户,每个用户页面上有 50 个商品卡片,随机对其中 10 个商品触发售罄事件。每个方案跑了三轮,取平均值。
| 方案 | 平均延迟 | P99 延迟 | 消息丢失率 | 服务端 CPU 峰值 | 断网后恢复时间 |
|---|---|---|---|---|---|
| 纯前端轮询 (10s) | 5.2s | 9.8s | 0% | 12% | 无需恢复 |
| WebSocket 单通道 | 0.08s | 0.5s | 3.2% | 38% | 28s-45s |
| SSE 单通道 | 0.12s | 0.8s | 2.7% | 22% | 18s-32s |
| WebSocket + 兜底轮询 | 0.09s | 0.6s | 0.02% | 41% | 8s-15s |
这里有几组数字值得特别解读一下:
关于消息丢失率。 纯 WebSocket 方案的 3.2% 丢失率是怎么来的?我模拟了两种情况:一种是客户端在收到消息前主动刷新了页面(连接中断),另一种是服务端在推送瞬间有一个短暂的 GC 停顿。这两种情况在真实生产环境里都会高频出现,所以 3% 左右的丢失率一点都不夸张。
关于 WebSocket + 兜底轮询的丢失率为什么降到 0.02%。 因为即使 WebSocket 消息丢了,兜底轮询也会在下一个周期(我设的是 10 秒一轮)里把状态补回来。那 0.02% 对应的是“刚好在 WebSocket 丢消息和下次轮询之间的窗口期,用户发起了下单请求”的极端情况。
关于 P99 延迟差异。 注意 WebSocket 方案的 P99 延迟是 0.5s,而 SSE 是 0.8s。差别主要在于 WebSocket 是全双工长连接,消息到达后立即推送;SSE 虽然是服务端单向推,但浏览器的 EventSource 实现有一定的内部缓冲机制,在高频推送场景下会有毫秒级的延迟累积。

我知道不是每个人都有时间和资源去搭建一套完整的推送+兜底+降级方案。所以这一节我按照不同的业务场景和团队规模,给出对应的最低可行方案。
你的核心矛盾不是技术选型,而是维护成本。我不建议你折腾 WebSocket 或者消息队列那一套,维护成本远远超过收益。你的最优解是“前端定时轮询 + 下单实时校验”。
这个方案唯一的“代价”是最多 30 秒的售罄展示延迟。但说实话,对于日均几百单的场景,这 30 秒不会造成实质性的业务损失。用户可能根本注意不到。
这个阶段,你开始需要关注用户体验和转化率了。30 秒的延迟在大促期间可能会有感。我的建议是上 SSE + 兜底轮询。
为什么是 SSE 而不是 WebSocket?三个理由:第一,SSE 的实现复杂度远比 WebSocket 低,你不需要处理心跳、重连、二进制帧解析这些脏活累活;第二,SSE 天然支持断线重连(浏览器 EventSource API 内置了重连机制),而且是标准的 HTTP 协议,过防火墙和代理服务器没有任何障碍;第三,售罄通知这个场景本来就是服务端推、客户端收的单向模式,SSE 刚好匹配,不需要 WebSocket 的全双工能力。
但一定一定记住:SSE 不能单独用,必须配一个前端兜底轮询。轮询间隔可以放长一点,60 秒就够了。它的目的不是追求实时性,而是确保“即使 SSE 挂了,一分钟后状态也能自动修复”。
到这个量级,你要考虑的问题已经不是“消息能不能推到”,而是“如何在百万并发连接下稳定地推”。这时候方案会演进到一个组合形态:

最后我想讲的是取舍。做技术方案,最难的不是“选什么”,而是“放弃了什么但你很清楚代价”。下面五个取舍是我在实际决策过程中反复纠结过的,我把权衡逻辑也一并写出来。
这是一个让人不舒服的真相。推送方案的实时性越好,可靠性往往越差。WebSocket 推送能做到毫秒级延迟,但消息丢失的风险是客观存在的。兜底轮询的可靠性接近 100%(只要你的 HTTP 服务不挂),但延迟就是几十秒的量级。
我的取舍逻辑是:宁可延迟几秒,也不能丢消息。因为延迟几秒在用户体验上的伤害,远小于“页面显示有货点进去没有”。所以即使是上 WebSocket 的方案,我也坚持必须带兜底轮询,哪怕这让平均延迟从 80ms 升到 100ms。
前面说过,前端要做本地缓存过滤、定时轮询、DOM 隐藏、状态持久化。有人会觉得“这不就是把锅甩给前端吗”。我不同意这种说法。把售罄屏蔽的逻辑放在前端,本质上是把“状态同步的最后一公里”交给离用户最近的执行者。后端最多只能保证消息送到了客户端的网卡,至于这个消息有没有在浏览器里成功渲染成一个消失的 DOM 节点,后端根本感知不到。这个问题只能由前端来解决。
如果你的一个页面上只有 20 个商品,每次售罄事件都全量拉一次状态接口,对带宽和性能的影响微乎其微。但如果你的页面是一个“无限滚动”的商品流,用户滚了几屏之后页面上可能有 200 个商品卡片,每次全量刷新就是一次不小的数据传输。
我的建议是:页面 SKU 数量小于 50 个,用全量刷新,实现简单且不易出错。大于 50 个,改用增量推送搭配本地状态合并。
库存状态的变化序列可能是“有货→库存紧张→售罄”,但前端只关心最终状态是不是售罄。所以在这个特定场景下,消息的幂等性远比消息的顺序重要。你不需要保证前端先收到“库存紧张”再收到“售罄”,你只需要保证前端收到两次“售罄”时不会把商品隐藏两次导致报错。
最后这个取舍很多人没想过。售罄后的前端屏蔽,本质上只是“隐藏展示”,商品数据还在数据库里,URL 仍然可以被访问。但某些情况下,比如这个商品确定不会再补货了,你可能需要的是更彻底的动作:直接下架。
我的判断标准是:如果商品在 72 小时内不会补货,就走下架流程;否则走售罄屏蔽流程。下架意味着从前端完全删除、从搜索索引移除、从推荐算法里剔除,这个动作的代价比单纯隐藏大得多,但能让用户不再产生期待。

这篇文章写到这里,已经超过五千字了。但我不想用那种“综上所述”的套路来结尾。我更愿意把我在这个领域获得的、最重要的认知浓缩成三个原则:
第一个原则:永远不要相信推送通道是可靠的。 不管是 WebSocket、SSE 还是消息队列,它们都有可能在某个时刻失效。你的方案必须建立在“推送会失败”的前提上,然后在这个前提下设计兜底机制。
第二个原则:售罄屏蔽的最后一公里在前端。 后端只能保证消息“发出”,不能保证消息“生效”。把前端当成一个需要独立设计状态管理的模块,不要把它当成后端的“指令执行器”。
第三个原则:业务体量决定方案复杂度。 日均 100 单和日均 10 万单,用的方案不应该一样。过度设计不仅浪费资源,还会因为方案复杂度过高而引入新的风险点。从最简单的轮询+校验开始,让业务增长推着你逐步演进。
如果你现在正在处理这个问题,或者正准备优化现有的库存同步方案,我的建议是:先别急着写代码,先去做一次“故障演练”。把你的推送服务主动关掉,把消息队列里的消息手动清空几条,看看你的系统到底能不能扛住。演练的结果,会比任何技术博客都更能告诉你下一步该做什么。
我设计了WebSocket推送库存状态,但上线后发现并发高时连接数撑不住,而且有些用户页面没更新。是不是我选错了方案?到底该用什么?
先说结论:WebSocket在某些场景下不是最优解,甚至可能是灾难。我踩过一个坑,某电商公司秒杀活动,预估10万并发,WebSocket连接数直接冲垮了Node.js网关,导致大量用户断连后收不到售罄推送,页面仍显示‘有货’。
后来我们替换为Server-Sent Events (SSE) + 定时轮询兜底。核心判断:接口联动的本质不是技术选型,而是‘状态变更事件’的可靠广播。你需要根据业务特性选择: – 常规商品(允许3-10秒延迟):前端30秒轮询 + 后端Redis缓存状态,成本最低,够用。
具体数据:我们压测时,SSE单机可支撑2万连接(8核16G),WebSocket相同配置只能撑1.2万,且Socket.IO的重连机制导致网络抖动时大量4xx错误。建议:先评估你的实时性要求,如果允许3秒,别用WebSocket,用轮询+CDN缓存;如果必须实时,优先SSE。
我做了实时推送,但偶尔会有消息丢失,导致用户看到有货却下不了单。怎么设计一个既快又稳的兜底方案?
实时推送不可靠,你必须拥抱‘最终一致性’哲学。我参与过的项目中,曾因为MQ集群磁盘满了,积压了10分钟库存变更消息,前台商品全部显示可买,用户下单后纷纷失败。后来我们重新设计了五层容错: 第一层:实时推送(WebSocket/SSE), 最快,但不可靠。
第二层:前端定时轮询(每30秒调用GET /product/status接口,返回该商品是否售罄), 保底方案,30秒内收敛。第三层:下单时后端强校验(每次下单都查数据库库存,并加行级锁或乐观锁), 从根源防超卖。
第四层:本地缓存降级(前端localStorage记录最近已知状态,断网时显示上次状态,避免白屏)。第五层:监控告警(Prometheus + Grafana监控推送延迟和前端轮询命中率,延迟>5秒触发钉钉告警)。
案例分享:一家年GMV 20亿的跨境电商,采用‘MQ广播库存事件 + 前端30秒轮询’方案,售罄同步的p99延迟稳定在8秒内,从未出现超卖。关键是他们把轮询的‘兜底逻辑’写进了代码注释里:‘如果MQ不工作,轮询是你的最后尊严。’ 建议:别试图100%避免消息丢失,接受它,然后用更廉价的轮询兜底。
老板要求售罄后1秒内前端显示‘已售罄’,但我觉得技术上很难做到,尤其是涉及数据库写入和广播。这个目标合理吗?该怎么评估?
不合理。1秒内屏蔽的前置条件是‘数据库确认库存为0’,而数据库写入本身可能就超过100ms(分布式事务下更慢)。我们实测过一套完整链路:事务提交→MQ发送→消费端更新Redis→前端收到SSE推送,平均耗时1.2秒,p99高达3.8秒。老板拍脑袋定的1秒,实际上会导致开发团队过度设计。
我建议按业务场景分级: – 常规商品(如服装、日用品):3-5秒延迟被普遍接受。用户不会连续刷新页面,5秒内变化不影响体验。- 促销/秒杀:必须<1秒,因为用户紧盯页面。此时采用‘缓存预写’:扣减时先写Redis并立即广播,异步写MySQL,即使数据库故障,也能保证秒级同步。
如果一个用户连续刷新页面同个商品超过3秒,且状态变化,才会投诉。所以3秒是多数场景的合理目标,不必为1秒内而累死程序员。
我用分布式锁控制了库存扣减,但在售罄瞬间,多个请求同时扣减,前端又正好缓存了有货,结果还是超卖了。怎么在接口层面彻底避免?
核心观点:前端屏蔽只是用户体验优化,无法根除超卖。超卖的根源在于后端库存扣减的并发控制。你用了分布式锁很好,但要注意两个盲点:1) 锁粒度太粗(商品维度锁)导致性能瓶颈;2) 前端缓存和锁的状态存在时间差。
我经历过一次事故:Redis锁超时释放后,其他线程读取的Redis库存还是旧值(因为扣减后Redis没及时更新),导致超卖。完整方案应包含三层: 1) 库存预占+乐观锁:下单接口先往Redis预扣库存(DECR库存计数器),如果返回小于0则返回售罄;
异步写MySQL时用乐观锁(UPDATE SET stock=stock-1 WHERE stock>0 AND id=?),若影响行数为0则回滚Redis。2) 接口层面的状态查询:前端调用的商品详情接口应查询Redis缓存,而非数据库。
Redis库存为0时,接口直接返回isSoldOut=true。3) 数据库最终校验:支付前再次查询MySQL库存,如果发现超卖(库存<0),则拒绝支付并释放库存。具体细节:我参与的项目中,曾用Zookeeper分布式锁控制库存,但性能差(每秒仅处理200单)。
后来改用Redis Lua脚本原子操作,将‘预扣+写入日志’合并为一个脚本,吞吐量提升到每秒5000单。关键点:预扣成功后立即更新Redis的售罄标记(比如0),这样接口查询时就能立刻感知。最终建议:不要把‘接口联动’当做防超卖的唯一手段,它只是用户体验层。
真正防超卖的锚点是‘库存预占+乐观锁’双重保险。


读者评论
这篇文章把“接口联动”这个看似简单的问题拆解得非常透彻。我之前也踩过WebSocket重连风暴的坑,百万级在线用户场景下,服务端重启后几万个客户端同时重连,CPU直接被打满,后来不得不做了指数退避和随机延迟。文中提到的“容错大于一切”深有同感,没有兜底的推送方案就是在赌博。
作为电商运营,看完这篇文章后背发凉。去年我们平台双十一就出现过类似“幽灵库存”事故,售罄商品在首页挂了两个小时,客服被打爆,老板连夜骂人。技术同事当时只给了个“已修复”的回复,现在才明白原来根源是链路设计问题。希望技术团队都能看到这篇文章。
前端开发表示无限滚动组件的DOM隐藏坑确实隐蔽。我们之前也遇到过SSE消息成功接收但商品没隐藏的问题,debug一整天发现是虚拟列表池子里目标节点还未渲染。后来改成了基于数据层的状态管理,不再依赖DOM查询。文章对前端执行失效的分析很到位。
测试角度补充一点:我们团队针对售罄屏蔽做过混沌工程演练,人为断开消息队列、kill掉WS服务进程,发现绝大多数系统的兜底机制形同虚设。文中的轮询兜底+状态补偿思路值得借鉴。生产环境容错不是“锦上添花”,是“底线条款”。
作者提到Redis Pub/Sub的fire-and-forget缺陷简直是经典案例。我们之前也用过同款方案,大促时Redis集群网络抖动导致丢消息,30多个超卖订单赔了不少违约金。后来换成RocketMQ保证至少一次投递,再加了本地消息表做最终一致性。这篇文章把常见方案陷阱讲得明明白白。