2635 字
13 分钟
抢票绝不超卖,以及分布式和微服务的区别

两个高频问题。第一个考的是「你知不知道保证在哪一层」,第二个考的是「你有没有被术语绕晕」。


20. 十万并发抢票,如何做到绝不超卖?#

先明确一件事:前面所有手段都不是保证#

限流、验证码、Redis 预扣、消息队列——这些全都是削峰,目的是保护系统不被打垮。它们一条都不能保证「绝不超卖」。

「绝不超卖」这个保证,只能由数据库那一层的约束提供。想清楚这一点,这道题就答对了一半。

漏斗:每层拦掉一个数量级#

上面演示的第一个标签就是这个结构。拖动并发量会发现一个关键现象:无论前面并发多大,最后落库的写请求数被库存量封顶

十万并发
↓ 网关限流 / 验证码 / 黑名单 拦掉 ~80%
2 万
↓ 本地库存标记(JVM 内存,售罄直接拒) 拦掉 ~75%
5 千
↓ Redis Lua 原子预扣 只放过 ≈ 库存量
1 千
↓ MQ 异步削峰 平滑写入
1 千 → DB 落库

本地库存标记这层最容易被漏掉,但性价比最高。 每台机器内存里放一个 volatile boolean soldOut,Redis 返回售罄后就地翻转,后续请求连 Redis 都不用查,直接失败返回。一个进程内的布尔判断,成本几乎为零,却能拦掉售罄后的绝大部分无效流量——而抢票场景里,售罄后的流量往往比售罄前还多。

Redis 预扣必须用 Lua#

分成两条命令就是竞态:

# ❌ 经典错误:check-then-act
if redis.get("stock") > 0: # 线程 A 和 B 都读到 1
redis.decr("stock") # 两个都扣,变成 -1

GETDECR 之间有时间窗口,高并发下必然被命中。正确做法是用 Lua 把判断和扣减放进同一次原子执行:

-- KEYS[1] = 库存 key, ARGV[1] = 用户 id
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -2 -- 已抢过,幂等
end
local n = tonumber(redis.call('GET', KEYS[1]) or "0")
if n <= 0 then return -1 end -- 售罄
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1]) -- 记录已购,防重复
return n - 1

Redis 单线程执行 Lua,整个脚本原子——这是 Redis 预扣能成立的全部依据。

顺带把一人一单也在这个脚本里解决了:用 Set 记录已购用户,同一个脚本内判断。如果放到脚本外面单独判断,又是一个 check-then-act。

最终防线:一条 SQL#

这是整道题的核心。判断和扣减必须合并成一条语句,由数据库的行锁保证原子性:

UPDATE ticket_stock
SET cnt = cnt - 1, version = version + 1
WHERE ticket_id = #{id}
AND cnt >= 1; -- 关键:条件写在 WHERE 里

然后检查影响行数:

int affected = stockMapper.deduct(ticketId);
if (affected == 0) {
throw new SoldOutException(); // 没扣成,直接失败
}

演示页第二个标签把这个对比动画化了——同样两个线程、同样只剩一张票,check-then-act 写法库存被扣成 -1,原子条件更新则第二个线程的 affected 为 0,直接失败。

再加一层兜底,让超卖在物理上不可能:

-- MySQL:无符号列,扣成负数直接报错
cnt INT UNSIGNED NOT NULL
-- 或者 CHECK 约束
ALTER TABLE ticket_stock ADD CONSTRAINT chk_cnt CHECK (cnt >= 0);

代码可能有 bug,约束不会。 这一层是给未来那个不知道这段历史的同事准备的。

分清两个目标:不超卖 vs 不少卖#

这是很多人答漏的一点:

要求手段
不超卖硬保证,绝不妥协DB 条件更新 + 列约束
不少卖尽力而为,最终一致对账 + 补偿

Redis 扣了但 MQ 消息丢了、或者用户下单后超时未支付——这些会导致少卖。少卖不可怕,可以接受短暂不一致,靠定时对账把库存回补:

定时任务:比对 Redis 预扣数 与 DB 实际订单数
差额 = 预扣但未成单 → 回补库存

超时未支付的订单用延迟队列自动取消并回库。把「不超卖」和「不少卖」混成一个问题,是这道题最常见的思路错误——前者要强一致,后者要最终一致,手段完全不同。

热点行才是真瓶颈#

所有请求都去更新同一行,行锁串行化,单行的更新 TPS 大概就几千。库存量大的时候这会成为瓶颈。

分段库存:把 1000 张票拆成 10 段,每段 100 张,请求按用户 ID 哈希路由到不同段:

stock_0: 100 stock_1: 100 ... stock_9: 100

锁竞争被摊到 10 行上。代价是可能出现「某段售罄但总库存还有」,需要做段间借调或者允许重试其他段——复杂度上来了,所以库存量不大的时候不要提前做这个优化。

完整链路#

用户请求
→ 网关限流 + 验证码 削峰
→ 本地售罄标记 快速失败
→ Redis Lua(预扣 + 一人一单) 原子,抗住十万并发
→ MQ 异步削峰,平滑写入
→ DB 条件更新 + 列约束 ★ 绝不超卖的保证在这里
→ 延迟队列取消未支付订单 不少卖
→ 定时对账补偿 最终一致

面试时如果只能说一句话:前面所有层都是性能优化,正确性的保证只有最后那条 WHERE cnt >= 1


21. 分布式和微服务有什么区别?#

一句话#

分布式解决「一台机器不够用」(技术驱动),微服务解决「一个团队 / 一个代码库不够用」(组织驱动)。

包含关系是单向的#

微服务一定是分布式的,分布式不一定是微服务。

三个反例足以说明问题:

  • 单体应用部署 10 个副本挂负载均衡 —— 这是分布式,但完全不是微服务。同一份代码、同一个仓库、同一个发布流程,只是跑了多份。
  • HDFS、Elasticsearch、Kafka —— 典型的分布式系统,但它们不是微服务架构。
  • Redis Cluster —— 分布式,同样不是微服务。

两个维度的对比#

分布式微服务
是什么部署形态架构风格
回答的问题怎么把一件事分到多台机器上做按什么边界拆分代码和团队
驱动力容量、可用性、性能团队自治、独立发布、技术异构
拆分依据数据分片、计算分片业务领域(DDD 限界上下文)
各节点代码通常相同(同一份代码多副本)各不相同(不同服务不同代码库)
典型代表HDFS、Redis Cluster、Nginx 集群订单服务、库存服务、用户服务

「各节点代码是否相同」是最快的判别方法。 十个节点跑同一份代码 = 分布式;十个节点跑十份不同代码、各有各的仓库和发布节奏 = 微服务。

微服务的代价,是为组织自治付的钱#

微服务带来的问题清单很长:

  • 分布式事务(本地事务没了,要上 Saga / TCC / 最终一致)
  • 链路追踪(一个请求横跨 8 个服务,出问题不知道在哪)
  • 服务治理(注册发现、熔断、降级、限流)
  • 数据一致性(数据库拆了,JOIN 没了)
  • 运维复杂度(20 个服务的部署、监控、日志聚合)
  • 本地开发困难(起一个功能要启动 6 个服务)

这些代价换来的是什么?团队能独立开发、独立发布、独立选型。

所以判断标准很直接:团队规模到不了那个量级,付了钱买不到东西。 三个人维护 15 个微服务,是在用分布式系统的全部复杂度,换一个根本不存在的组织协作问题。

Martin Fowler 那句「你必须足够高才能微服务」(You must be this tall to use microservices)说的就是这个——先具备自动化部署、监控、快速故障定位的能力,再谈拆分。

那单体就是落后的吗#

不是。模块化单体(Modular Monolith)在很多场景下是更优解:代码内部按领域清晰分模块、有明确的模块边界和依赖规则,但部署成一个进程。

这样既有清晰的边界(将来真要拆,沿着模块边界拆就行),又没有分布式的代价。

推荐的演进路径:

模块化单体 → 边界稳定后 → 按需拆出压力最大 / 变更最频繁的模块

先拆逻辑边界,再拆物理部署。 反过来做——边界还没想清楚就先拆服务——是微服务实践失败的主要原因:边界划错了之后,服务间会产生大量的同步调用和分布式事务,比单体还难维护。

一个常见的混淆#

「我们用了 Spring Cloud,所以我们是微服务」——框架不等于架构

用了注册中心和网关,但所有服务共用同一个数据库、任何改动都要一起发布、拆分依据是技术分层(controller 服务、service 服务、dao 服务)——这是分布式单体(Distributed Monolith),公认的最差架构:既有微服务的全部代价,又没有微服务的任何收益。

判断是不是分布式单体,问三个问题:

  1. 能独立发布吗? 改一个服务要不要协调其他服务一起发?
  2. 数据库独立吗? 多个服务直连同一个库、甚至同一张表?
  3. 拆分依据是业务还是技术分层? 按 controller/service/dao 拆是技术分层,不是微服务。

三个里有两个答错,那就是分布式单体。


小结#

两道题其实有个共同点:都在问「保证 / 收益到底来自哪一层」

抢票那题,削峰的手段有五层,但正确性只来自最后的数据库约束——把 Redis 预扣当成防超卖手段,是把性能优化误认为正确性保证。

微服务那题,分布式带来的是容量和可用性,微服务带来的是组织自治——把两者混为一谈,就会在三人小团队里为了「高可用」拆出十五个服务,然后被分布式事务和链路追踪拖垮。

先想清楚要买什么,再看这个代价值不值得付。

抢票绝不超卖,以及分布式和微服务的区别
https://gilgameshzzz.github.io/posts/ticket-oversell-and-microservices/
作者
Amadeus
发布于
2026-09-18
许可协议
CC BY-NC-SA 4.0