订单与支付场景
开篇:电商订单系统的三大死敌 -- 超卖、重复支付、并发冲突
做电商系统的人,迟早会在凌晨被三种报警惊醒:库存扣成了负数、用户被收了两遍钱、两条一模一样的订单静静地躺在数据库里。这三个问题看上去各自独立,但根源都是同一件事 -- 高并发下共享资源的争抢没处理好。
这篇文章把订单和支付领域最常见的实战场景拉出来过一遍:秒杀如何防超卖、支付如何做幂等、表单如何防重复提交、状态机怎么设计才不会翻车、分库分表怎么落地。每个场景都带真实方案,能直接拿来用。
一、秒杀系统设计实战
定义问题比解决问题更重要
拿到"设计一个秒杀系统"这种题,先别急着写方案,先拆解它到底有哪些子问题:
- 高并发瞬时流量:几千万请求瞬间涌入
- 热点数据:所有人抢同一件商品
- 库存正确扣减:不能超卖也不能少卖
- 黄牛防刷:脚本和批量账号的攻击
- 重复下单:用户疯狂点击提交按钮
- 对普通交易的影响:秒杀不能拖垮正常购物
- 数据量大:大量订单的存储和查询
定义清楚了,方案自然浮出水面。
逐层过滤:离用户越近越好
秒杀的核心哲学是逐层流量过滤 -- 在离用户最近的地方尽早拦截无效请求,让真正能成交的请求才打到数据库。
一次用户秒杀请求会经过客户端、CDN、Nginx、Web 应用、缓存、数据库。首先,秒杀页面的静态资源提前推到 CDN,让静态资源离用户更近、访问更快。客户端层面可以做一层随机丢弃 -- 被丢弃的请求直接返回"系统繁忙请重试",过滤掉一部分流量。
Nginx 层配黑白名单和 IP 限流,还可以做一些基本的业务校验。应用层再用 Sentinel 做动态限流。
对于查询操作和部分写操作,本地缓存要比分布式缓存性能更高,近端缓存优于远端缓存。秒杀是可以提前预知哪些数据会成为热点的,所以不仅要在 Redis 中做预热,本地缓存也要预热,避免 Redis 的热 key 问题。
因为秒杀业务本来就支持失败,所以我们可以大方地通过层层过滤,让更多的流量在前面就被消化掉。
Redis + Lua:库存扣减的终极方案
为什么不用分布式锁?答案是没必要。分布式锁的每次操作需要加锁、读库存、扣库存、释放锁,多次网络往返。而 Lua 脚本在 Redis 服务器内部一次性完成所有操作,利用 Redis 单线程机制天然串行化:
local key = KEYS[1]
local amount = tonumber(ARGV[1])
local stock = tonumber(redis.call('get', key))
if stock >= amount then
redis.call('decrby', key, amount)
return redis.call('get', key)
else
return "INSUFFICIENT STOCK"
end这段脚本做了三件事:取库存、判断够不够、扣减。整个过程原子执行,不会被打断。如果用分布式锁加 Lua 脚本呢? -- 那就是脱裤子放屁,Lua 脚本的执行本身就是串行化的,锁完全多余。
而且 Lua 脚本直接操作内存中的数据,执行效率极高。用分布式锁则需要管理锁的存活时间、处理死锁,一旦管理不当反而引发新问题。逻辑集中在 Lua 脚本中,应用层代码也更加简洁。
Redis 库存怎么不被淘汰
用 Redis 做库存扣减,有个容易忽略的细节:这份库存数据会不会被 Redis 的淘汰策略清理掉?
这其实是 Redis 淘汰策略的问题。allkeys-lru 会对所有 key 做 LRU 淘汰,适合纯缓存场景 -- 数据没了就没了,大不了回源。而库存是一种"半缓存半持久化"的场景,适合 volatile-lru 策略 -- 只有设置了过期时间的 key 才参与淘汰。只要库存的 key 不设过期时间,就不会被淘汰掉。这也是阿里云 Redis 的默认策略。
数据库扣减方案
如果不用 Redis,纯靠数据库扣减也能做。关键是不要先查后扣,而是把校验放进 SQL 里:
UPDATE inventory SET quantity = quantity - #{count}
WHERE sku_id='123' AND quantity >= #{count}这条 SQL 利用了 update 自带的行级锁,只有余量够才能执行成功,避免超卖。但瓶颈也很明显 -- MySQL 的热点行更新最多抗 200-300 QPS。正常的 update 竞争抢锁时也是排队的,但这种排队会让事务持续自旋抢锁,严重耗费数据库 CPU。
想要更高并发,可以用阿里云的 Inventory Hint 或腾讯云的热点更新优化 -- 它们在数据库执行层对同一热点行的 update 做轻量级排队,不需要自旋,不需要抢锁,性能大幅提升。
其他思路还有:
- 拆分:把一个大库存拆成多个小库存(类似 ConcurrentHashMap 的分段锁),降低锁粒度。但存在碎片问题 -- 1000 个库存分到 10 份,就没法一次扣 999。需要做库存腾挪或超占,方案不够完美。
- 合并:把多个 update 合成一个批量执行。比如 10 条各扣 1 个的请求,合并成一条扣 10 个。降低锁冲突,但需要窗口时间聚合,秒杀场景下用户不能接受等待。适合异步链路。
- 转 insert:把 update 转成插入占用记录,异步统计剩余库存或用 SQL 聚合流水计算。
Redis 扣减后的一致性保障
Redis 扣减之后,通过 MQ 异步通知数据库做真正的持久化扣减。这套组合拳能保证最终一致性,但要注意少卖问题:Redis 扣成功了,MQ 丢了或消费失败,数据库没扣 -- 结果就是 Redis 多扣了,商品少卖了。
解决方案是引入对账系统。可以用 Redis 的 zset 记录扣减流水,定时拉一段时间内的记录和数据库比对,发现不一致就补偿。成熟的电商公司不管前面方案做得多完善,对账系统都是必不可少的。
黄牛防刷与重复下单
防黄牛靠两条腿走路:风控算法识别可疑用户(基于 IP、设备、行为数据),识别出来的直接加黑名单,在 Nginx 和应用层同时拦截。对 IP 和设备做限流,比如限制某个 IP 一段时间内只能下单几次,基于令牌桶、漏桶等限流算法实现,可以直接借助 Nginx、Sentinel、Guava 做限流。
防脚本直接调接口,用 Token 机制 -- 用户访问页面时发放 Token,下单时带上来做校验,Token 合法则接受请求并让 Token 失效,不合法或已失效则直接拒绝。
防重复下单同样依赖 Token。页面不刷新 Token 不变,基于 Token 做幂等判断。再结合限购逻辑(检查用户是否已有在途订单),双重保险。
为什么 Redis 能保证先来先得
100 个优惠券,几千万流量,怎么保证最前面的人能抢到?
限流和分布式锁都做不到。限流按秒级做统计,一秒内谁被放行谁被拦截全凭运气。分布式锁也是抢锁机制,后来的可能刚好碰上没人持锁就直接拿到了。
而 Redis 的命令执行天然按发送顺序串行,先到的请求先扣减。库存为零后所有后续请求直接拒绝,根本不会打到数据库。如果做得更好一点,在 Redis 中维护一个"是否还有库存"的标识甚至缓存到本地,可以进一步减少 Redis 请求、降低应用被打垮的概率。
隔离与业务手段
秒杀不能影响正常购物。物理隔离最彻底 -- 前后端服务和数据存储完全独立部署。退一步也至少做逻辑隔离:在订单、商品上打秒杀标记,方便后续查询和数据分析。
别忘了业务手段也能大大简化技术方案:预约制降低秒杀时的请求量、预售功能分散流量、验证码降低瞬时并发和脚本刷单风险。有时候业务上动动手指,能省下技术上大量的复杂度。
秒杀期间加库存
看上去比扣减更简单,其实原理一模一样。Redis Lua 脚本做原子增加(incrby),再异步同步到数据库(quantity = quantity + #{count})。
唯一需要注意的是:很多时候运营不知道要"加多少",只知道要"加到多少"。这就需要在库存设置的后台提供"增量加"的入口,或者在加的那一刻取当前值算出差值。高并发扣减下可能不完全精确,但影响通常不大。
二、支付幂等性设计
故事一:一笔钱被两个渠道各收了一次
用户先用微信支付,网络延迟一直没返回结果,又切到支付宝付了一次。过了一会儿微信那边也回调通知支付成功了。
解决思路:在支付单上冗余支付渠道和渠道支付单号。收到支付成功回调时,先检查状态是否已成功,再比对渠道和单号。如果一致就是同一笔的幂等回调,直接返回成功。如果不一致,说明重复支付,触发冲退流程。
故事二:超时关闭和支付成功在同一秒撞车
10:00 下单,超时 1 小时。11:00 整,支付成功和超时关闭的请求同时到达。
核心原则是明确终态 -- 支付成功和已取消都是终态,终态不可逆。如果一个模型的终态还能随便变化,那设计一定有问题。靠状态机校验 + 乐观锁在 SQL 层面做互斥:
-- 两条 SQL 同时竞争,只有一条能成功
UPDATE pay_order SET status='PAY_SUCCESS', lock_version=lock_version+1
WHERE pay_order_no=#{no} AND status='PAYING' AND lock_version=#{v}
UPDATE pay_order SET status='PAY_EXPIRED', lock_version=lock_version+1
WHERE pay_order_no=#{no} AND status='PAYING' AND lock_version=#{v}如果超时处理先赢了怎么办?用户把钱付了但支付却失败了。办法是原路退回 -- 发现支付单已关闭,立刻触发退款。
为什么不能让已取消的订单再变回成功?一方面终态流转不合理,另一方面超时关闭的逻辑可能已经退了库存、释放了营销券,补一个支付单并不现实。
引入资金恒等式做对账保障:
- 支付成功时:
支付金额 > 0;冲退金额 = 0 - 超时关闭时:
支付金额 - 冲退金额 = 0
对账任务不断校验等式,不一致就触发退款重试,直到等式成立。在前面的基础上再加分布式锁 -- 进入支付成功/超时的业务逻辑前先抢锁,没抢到就重试,这样能大幅降低并发冲突和线上报错。
下单支付全链路的问题清单
从点击支付到跳转订单页,这个流程可以展开聊一整场面试。需要处理的问题至少包括:
- 支付跳转失败 -- 第三方支付渠道异常时做故障转移。实时监控各渠道成功率,低于阈值(比如 80%)就不展示或限流该渠道,成功率恢复后再恢复展示。限流、降级、熔断一把梭。
- 支付状态未同步 -- 第三方支付结果一般通过回调通知。前端同时轮询支付单状态。还要提供"如页面未跳转请点击"的主动查询链接(点击后向后端发起查询,后端主动查询支付渠道),以及兜底定时任务。
- 支付成功后库存未扣减 -- 跨系统一致性问题,用 TCC 或 AT 方案解决。不建议用 MQ -- 支付场景对一致性要求高,MQ 的最终一致性有延迟。
- 用户不支付卡在支付中 -- 超时关单。让渠道的超时时间比自己系统的短一点,让渠道先超时回调。如果渠道没回调,自己到时间后也主动调渠道的关单接口。
- 支付数据被篡改 -- 成熟的第三方支付都有加签验签和加密解密,按要求接入即可。
- 第三方接口成功但调用方显示失败 -- 最常见的原因是网络抖动:调用第三方时超时了,调用方以为失败就改了状态,但第三方其实已经处理成功。还有异步处理延迟(外部还没来得及回调)、回调处理失败(应用正好在重启)等。解决方案核心是不要只依赖同步调用的结果,要有回调机制、主动查询、重试策略和对账系统多重保障。
三、防止表单重复提交
表单重复提交的场景处处可见:用户双击按钮、网络抖动触发重试、脚本恶意刷接口。解决方案分五个层次,从简单到完善逐步升级。
第一层:前端按钮置灰。 用户点击后立即禁用按钮,成本最低但最不可靠 -- 有的场景还没来得及置灰就双击了,技术用户也能绕过。
第二层:Token 机制。 用户访问页面时向后端请求 Token(存入 Redis 并设过期时间),提交时带上来。后端用 Redis 的 DEL 命令做原子校验 -- DEL 返回 1 表示存在且删除成功,返回 0 表示已被使用或不存在:
public void verify(String token) {
Jedis jedis = new Jedis("localhost");
if (jedis.del(token) == 1) {
// 校验成功,继续业务
} else {
// 重复请求,拒绝
}
jedis.close();
}实现简单,但多了一次获取 Token 的网络请求。还要防止有人提前刷一批 Token 来绕过防重 -- 可以针对"同一商品 + 同一用户"维度来生成 Token,确保同用户在同商品下同时只有一个有效 Token。
第三层:Redis SETNX。 把能代表本次请求唯一性的业务字段通过 SETNX 写入 Redis 并设置过期时间。写成功说明是首次请求,写失败说明已处理过。
第四层:数据库唯一索引。 在去重表上对请求的特殊字段建唯一索引,插入成功则继续业务,违反唯一约束则拒绝。MySQL 的容错性会影响业务,高并发环境效率偏低,但可靠性高。
第五层:乐观锁 / 悲观锁。 数据库层面通过版本号(乐观锁)或 SELECT ... FOR UPDATE(悲观锁)控制并发。给要处理的数据设一个状态字段,也是同样的思路。
不用 Redis 分布式锁的降级方案:Token + 数据库校验、滑动窗口限流(限制一分钟内只能发起一次请求)、布隆过滤器做 fail-fast 快速校验(不存在则一定不存在直接放行,命中则再查数据库确认)。Redis 本质就是集中式存储,不可用时一般降级到数据库。
购物车防重复下单
购物车场景下 Token 方案需要调整 -- 用户刚进购物车时你不知道他要买哪些商品,没法基于"品 + 用户"维度生成 Token。
方案是每个商品在首次加入购物车时生成全局唯一的 cart_item_id。具体规则:
- 所有用户的
cart_item_id都不一样 - 已有 SKU 加购只修改数量,不生成新 ID
- 商品被购买后再次加入购物车,生成新 ID
伪造 cart_item_id 也不怕 -- 下单时校验这个 ID 是否存在于该用户的购物车中(后端加购时生成并存储的),不存在直接拒绝。cart_item_id 设 3-5 分钟过期即可。
自定义注解方式
实际项目中经常用注解 + 拦截器的方式实现防重:拦截器在请求进来时把请求地址、参数、Authorization 组合成 key 存入 Redis 并设置过期时间,下次同样的请求在指定时间间隔内再来直接拒绝。代码侵入最小,Controller 方法上加个 @RepeatSubmit 注解就能生效。类似 RuoYi 框架中的实现思路。
四、订单状态机设计
支付状态机的基本原则
一个健康的状态机必须有明确的终态,且终态不可逆。
所有涉及状态变更的 SQL 都必须带上前置状态校验:WHERE status='PAYING'。再加上乐观锁的版本号字段,就能在数据库层面保证同一笔单据只会被推进到一个终态。
订单到期关闭的选型
到期关闭的实现方案很多,按推荐程度排列:
| 方案 | 适用场景 | 核心优缺点 |
|---|---|---|
| 定时任务 | 时间精度要求不高 | 简单可靠;时间不精准、大订单量需优化 |
| Redisson 延迟队列 | 分布式高可用 | 基于 zset 自带去重;实现稍复杂 |
| RocketMQ 延迟消息 | 已有 MQ 基础设施 | 开源版时间档位有限;5.0 后支持任意时间 |
| RabbitMQ 插件 | 已有 RabbitMQ | 无消息阻塞问题;最大延迟约 49 天 |
不建议使用的方案及原因:
- Redis 过期监听:官方明确说不保证 key 过期时能立即删除和发出消息,延迟个几分钟很常见。5.0 之前基于 PUB/SUB 没有持久化,客户端挂了消息就丢了。
- JDK DelayQueue / Netty 时间轮:基于内存,重启丢数据,集群扩展难。只适合单机小数据量场景。
- RabbitMQ 死信队列:可以设置任意 TTL,但存在队头阻塞问题 -- 队头消息消费不掉会阻塞后面所有消息。
MQ 方案在大订单量下的隐患: 大部分订单会提前取消或完成支付,产生大量无效延迟消息浪费资源。MQ 也不是 100% 不丢消息。B 类采购订单的超时时间可能很长,超出 MQ 支持的延迟上限。
最务实的选择: 定时任务(结合 XXL-JOB 分片任务 + 多线程优化延迟),再加 Redisson 做补充。阿里内部的超时中心(TOC)就是基于定时任务实现的。
定时任务的常见问题和优化:
- 时间不精准 -- 结合分片任务缩短扫描周期
- 大订单量扫描慢 -- 多机分片 + 多线程并行
- 数据库压力大 -- 做好隔离,限制扫表并发
- 分库分表下的全表扫描 -- 极不推荐,需要设计好分表策略避免
五、分库分表与订单号设计
分库还是分表?
搞清楚目标再动手:分表解决数据量大的问题,分库解决并发高的问题。高并发往往伴随大数据量,所以经常一起做,但不绝对。有些系统就是并发不高但积累多年数据量很大,分表就够了。
垂直分库是把不同业务的库拆开(订单库、库存库、商品库),但在订单系统内部不太涉及。垂直分表在订单中可以做 -- 把订单拆成主表和扩展表,减少字段数降低存储量提升查询效率。水平分库分表则是把数据水平打散到多个库/表中,是大型订单系统的标配。
一般来说,垂直分表、水平分库、水平分表在订单系统中都会做。
分表数量公式
分表数量 = (存量数据 + 年增量 x 保留年限) / 2000万 => 向上取最接近的 2 的幂
分库数量 = 分表数量 / 8(经验值)例如:存量 2000 万,年增 500 万,保留 10 年 => (2000 + 500*10) / 2000 = 3.5 => 4 张表。分表数量小于 8 的话,分库数量直接等于分表数量。
常见搭配:128 库 1024 表、64 库 512 表、16 库 128 表、8 库 64 表。
按买家 ID 分表 + 基因法
电商订单中最重要的三个字段:买家 ID、卖家 ID、订单号。分表字段首选买家 ID -- 如果按卖家 ID 分表,大卖家的数据量极大,某张表数据量远超其他表,查询还是很慢。按买家分则均匀得多。
分表之后两个衍生问题:
卖家查询怎么办? 通过 binlog 同步一份按卖家 ID 分表的副本库。同步过程中基于卖家 ID 重新计算分表编号。卖家查询走副本,所有写操作仍走买家库。即使卖家库有倾斜也没关系,它只承担低频查询。大卖家还可以在卖家分表算法中做特殊拆分。需要注意:卖家库只用于查询,不做任何写操作。
按订单号查询怎么办? 使用基因法 -- 生成订单号时就把分表结果编码到订单号的固定位置上。查询时解析出分表编号,直接定位到对应的表,不需要全表扫描。
订单号组成:[2位业务类型] + [18-20位唯一ID(雪花/Leaf)] + [4位分表基因]
在大型电商系统中,买家 ID 分表 + 同步卖家表 + 基因法生成订单号 + 历史订单数据归档,是比较常见的组合拳。
按时间分表
按时间分表(按年份或按冷热数据窗口)也是一个选项。好处是简单、按时间查询快、方便归档和备份。坏处是查询不带时间就要跨表扫、最近的数据有热点问题、双十一月份数据严重倾斜。
所以在订单系统中,按时间分表通常做历史表或数据归档用,核心分表字段还是买家 ID。
分表算法和订单号设计
分表算法用取模就够了:buyer_id % 分表数量。买家 ID 不是数字就先哈希再取模。别搞太复杂 -- 拿到一个买家 ID 肉眼就能算出在哪张表,多好。
订单号生成服务要考虑的要点:唯一性(UUID / 雪花 / Leaf)、可读性(业务类型编码方便区分交易订单、支付单、结算单)、基因法(编码分表结果)、预留足够位数应对数据量增长、高性能(内存缓存、异步处理)、高可用(多节点部署、负载均衡、健康检查)。
六、高频实战场景
余额防超花
账户只有 10 块钱,两笔总额超过 10 块的扣款同时到达。三种方案递进升级:
方案一:分布式锁。 先对账户加锁,再查余额、判断、扣减。安全但并发度低 -- 加锁会让大量线程阻塞。
方案二:数据库乐观锁。 直接在 SQL 里控制:
UPDATE account SET balance = balance - #{amount}
WHERE account_id='123' AND balance >= #{amount}锁的粒度极小(只在 update 瞬间有行级互斥锁),并发度远高于分布式锁。这本质上是乐观锁思想,只不过 where 条件不是 version 而是 balance >= #{amount}。
方案三:Redis Lua 扣减。 和库存扣减同理,Lua 脚本在 Redis 里完成余额校验和扣减的原子操作。能抗的并发量远高于 MySQL,但需要解决 Redis 和数据库之间的一致性问题(MQ 异步同步 + 旁路 + 对账)。
TCC 拆解:库存扣减 + 创建订单
TCC 和 2PC 最大的区别:Try/Confirm/Cancel 各自是独立事务,不是一个大事务包到底。
| 阶段 | 库存操作 | 订单操作 |
|---|---|---|
| Try | 冻结库存:frozen_inventory + count | 创建 INIT 订单(用户不可见) |
| Confirm | 解冻并扣减:frozen -count, saleable -count | 推进到 TO_PAY(用户可见可支付) |
| Cancel | 解冻:frozen -count | 状态改为 DISCARD(可定期清理) |
冻结库存后,用户看到的可用库存 = 剩余库存 - 冻结库存,起到资源预留的效果。如果 Confirm 之后其他参与者失败,库存要回退 saleable + count,订单同样改 DISCARD。
电商下单的一致性方案选型
一个下单链路涉及订单、库存、积分、邮件通知、外部保险五个系统。不能一刀切 -- 不同系统对一致性的要求天差地别:
为什么不全用强一致?成本太高,而且会让下单成功率大大降低。我们不能因为加积分失败就让下单失败。
订单和库存要求强一致 -- 下单成功库存必须扣减成功(否则超卖),库存扣减成功订单也必须创建成功(否则少卖)。用 TCC 或 XA。
订单和积分要求最终一致 -- 积分增加一定要成功但可以有延迟。用本地消息表或 MQ 事务消息。
订单和邮件通知要求最弱 -- 丢了对业务影响不大。最大努力通知即可。
外部保险最特殊:可用性远低于电商平台。在协议上约定"风控通过即投保成功,可先资金流后信息流",技术上做成离线文件每天同步一次。
一锁二查三更新的经典坑
下面这段代码看上去很标准,实际上会出脏数据:
@Transactional(rollbackFor = Exception.class)
public boolean order(Request request) {
RLock lock = redisson.getLock(request.getIdentifier());
try {
lock.lock();
OrderDo order = orderMapper.find(request.getIdentifier());
if (order != null) return false;
orderMapper.insertOrder(request);
} finally {
lock.unlock(); // 锁释放了...
}
orderStreamMapper.insertOrder(request); // ...但 @Transactional 的事务还没提交!
eventMapper.insert(request);
return true;
}问题出在事务粒度大于锁粒度 -- @Transactional 包住了整个方法,事务要等第 28 行之后才提交。而锁在第 20 行就释放了。另一个线程拿到锁后查询数据,因为事务隔离性看不到未提交的数据,结果重复写入。
修复:用编程式事务缩小事务范围,确保事务在锁释放之前就提交完毕:
public boolean order(Request request) {
RLock lock = redisson.getLock(request.getIdentifier());
try {
lock.lock();
OrderDo order = orderMapper.find(request.getIdentifier());
if (order != null) return false;
transactionTemplate.execute(status -> {
orderMapper.insertOrder(request);
orderStreamMapper.insertOrder(request);
eventMapper.insert(request);
return null;
});
return true;
} finally {
lock.unlock();
}
}加了分布式锁会降低并发?
面试官常追问:"加了分布式锁不是影响并发了吗?"
需要区分两种"并发":分布式锁拦截的是同一个用户的重复异常请求,这种并发本来就不该发生。系统的整体并发(QPS)是所有用户的正常请求总量。拦住异常请求反而释放了系统资源,让更多正常请求得到处理。通过加锁防止了异常并发,系统整体吞吐反而上来了。
本地消息表的回滚困境
本地消息表不适合需要回滚的场景。它适合的是"不回滚,必须成功"的场景 -- 比如下单后给用户投保运费险,不能因为投保失败就取消订单,只能不断重试直到成功。
如果面试官非要回滚?多次重试不成功的消息进死信队列,上游监听死信队列做本地事务回滚(回滚前建议反查下游接口确认真的失败了)。但说实话,一旦有多个消费者部分成功部分失败,系统根本处理不了。
务实的做法:多次投递不成功就记录报警,靠人工介入或对账机制处理。这种场景发生概率很低,一旦发生往往有特殊原因,需要人来判断。
设计购物车功能
购物车存储不需要保存商品所有信息,只存 SKU ID + 数量 + 时间戳。渲染购物车时实时反查商品的库存、价格、介绍等信息。
未登录用户: 数据存客户端(Cookie / LocalStorage),JSON 格式:
{ "cart": [
{ "SKUID": 10086, "timestamp": 1666513990, "count": 2 },
{ "SKUID": 10010, "timestamp": 1666513990, "count": 10 }
]}不需要标识数据属于谁,因为用户没登录。
已登录用户: 需要持久化存储。数据库方案直接建表(user_id, sku_id, count, timestamp)。Redis 方案以 user_id 为 key 存 JSON。Redis 响应快并发高,数据库可靠性好支持事务和复杂查询。
断网加购怎么办? 客户端用本地数据库(如 SQLite)缓存加购请求,监听网络状态恢复后触发同步,用指数退避重试策略避免频繁请求。服务端做好幂等控制(唯一请求 ID)防止重复处理。
抢红包的实现
拼手气红包核心算法是二倍均值法:剩余金额 M,剩余红包 N,每个红包上限 (M/N)*2,在 [0.01, 上限] 范围内随机取值。这确保了每个人都有机会抢到不同金额,后面的人也有机会抢到较大的红包,同时保证每个红包最低 1 分钱。
微信群人数有上限且红包数量有上限,所以单个红包的并发度其实可控,热点问题并不明显。金额是实时计算的而非提前生成 -- 因为有些红包不会被抢完要退回,提前算好会浪费空间。
百万级会员到期提醒
百万级数据量不算大,单表就能扛,根本不需要分库分表。核心实现思路:
- 识别到期用户:
expire_time字段加索引,用范围查询(BETWEEN CURDATE() + INTERVAL 3 DAY AND CURDATE() + INTERVAL 4 DAY)而不是DATEDIFF函数 -- 函数会导致索引失效 - 高效扫描: 用 XXL-JOB 分片任务按用户 ID 尾号分片,10 台机器并行扫描。用 reverse_user_id(反转ID)做
LIKE '0%'前缀匹配以走索引 - 异步推送: MQ 解耦扫表和推送,消费端批量拉取 + 多线程并发
- 防疲劳: Redis 设
user_id:message_typekey 加 24h 过期控制频率。特殊时间段(晚 10 点后、节假日)前置拦截 - 异常处理: 消息推送表记录状态和重试次数,超阈值停止重试并报警。历史数据定期归档
消息推送表的核心字段:user_id、message_type、channel(sms/email/app_push/站内信)、content、status(待发送/已发送/发送失败/已重试)、retry_count、third_msg_id(第三方消息 ID 用于对账)、send_time。
七、消息队列相关的订单场景
Spring Event 和 MQ 怎么选
Spring 的 Event 机制和 MQ 看上去都是生产者-消费者模式,都支持一发多收,都能做异步(Spring Event 默认同步但可配线程池异步执行)。很多事情两者都能做到。
但差别在关键的几个地方:
- 持久化:MQ 的消息可以持久化到磁盘,Spring Event 基于 JVM 内存,应用重启或崩溃未处理完的事件就没了
- 削峰填谷:MQ 有队列做缓冲,Spring Event 没有缓冲
- 失败重试:MQ 自带重投机制,Spring Event 失败了没法自动重试
- 高级功能:MQ 有延迟消息、顺序消息、事务消息等,Spring Event 再怎么花式也只是自定义线程池
但 Spring Event 也有它的好处 -- 简单,非常简单。
实际选型原则: 自己业务内部做一些解耦或一发多收(比如订单创建后异步创建流水),用 Spring Event + 线程池。但要有补偿机制(定时任务兜底失败情况)。多个系统之间交互、需要延迟消息/顺序消息/事务消息、并发量大需要削峰 -- 这些场景只能用 MQ,Spring Event 没法跨 JVM 进程。
消息表的设计
为了避免消息丢失问题需要落表,这张本地消息表该怎么设计?核心字段分四组:
基本信息: id(主键)、gmt_create、gmt_modified、lock_version(乐观锁)
消息信息: message_key(业务唯一标识,做幂等)、message_id(MQ 返回的 ID,发送成功后才有)、message_type(消息类型,扫表时根据类型执行不同逻辑)、topic、message_body(序列化存储)
状态信息: state(待发送/已发送/已失败/已消费/已挂起)
已失败状态的消息不是完全放弃 -- 可以降低重试频率(比如待发送的 10 分钟重试一次,已失败的 6 小时重试一次),或者报警出来人工跟进。
任务执行信息: retry_count、next_retry_at、last_retry_time、fail_reason
索引设计:state 做普通索引(或和 retry_count、next_retry_at 建联合索引)用于高效扫表;message_key + message_type 建唯一索引防重复插入。
next_retry_at 可以让不同消息设置不同的重试间隔;last_retry_time 方便扫表时设过滤条件(只扫没重试过的,或 10 分钟前的)。
为什么不用 BlockingQueue 做消息队列
BlockingQueue 是本地队列,在某些场景下可以凑合用,但问题很多:
- 无分布式特性 -- 无法跨节点共享数据,可能流量倾斜(一台机器任务堆积,其他机器空闲)
- 无持久化 -- 内存中的数据,应用崩溃或重启就丢了
- 无高级特性 -- 不支持消息确认、重试、死信队列、延迟消息、事务消息
- 无管理监控 -- 没法看队列状态、消息积压情况、消息轨迹
- 性能受限 -- 单机环境,无法水平扩展
专业的 MQ(Kafka、RocketMQ、RabbitMQ)在以上每一点都有完善的支持。BlockingQueue 只适合做应用内部的简单异步处理。
八、分布式架构的取舍
分布式一定比单体好?
答案当然是不一定。两种架构各有适用场景。
单体架构的好处: 开发测试部署都简单(一个项目搞定),不需要网络交互(大大降低延迟),不需要考虑分布式事务、分布式锁、分布式 ID 等问题。
单体架构的问题: 单机性能有瓶颈(硬件资源有限),开发效率随团队增长下降(代码合并痛苦),单点故障影响面大(一个模块 OOM 全部受影响),技术栈单一且老旧。
分布式架构的好处: 容易水平扩展(加机器抗更高并发),模块化独立开发部署(互不影响),减少单点故障,技术栈多样化。
分布式架构的问题: 运维复杂(多服务部署监控日志),CAP 约束(可用性、一致性的权衡),问题排查困难(一个请求经过多个系统,需要分布式链路追踪)。
结论: 项目规模小、团队人数少、开发周期短、技术不复杂 -- 单体就够了。项目规模大、需求复杂、业务增长快、需要高并发高可用 -- 才上分布式。
分布式 Session
用户登录信息保存在服务器 A,服务器 B 怎么获取?归根结底就是分布式 Session 的问题。
最常用的方案:Redis。 把用户登录信息存 Redis,所有服务器都从 Redis 中读取 Session:
除了 Redis 也可以用 MySQL、MongoDB 等第三方存储。其他方案包括客户端存储(Cookie/JWT)、粘性 Session(负载均衡绑定)、Session 复制(服务器间同步),但都不如 Redis 方案通用。
九、消息队列的设计思考
如果让你实现一个消息队列
这是一个典型的系统设计场景题,核心是基于对 Kafka、RocketMQ 等的理解做拆解。
基本架构: 生产者发送消息、Broker 做存储和投递、消费者接收处理。主题(Topic)做消息分类,分区(Partition)做物理分割提高吞吐量。
存储方式: 内存存储快但可能丢消息,磁盘存储慢但持久化。一般磁盘存储 + 操作系统缓存。
通信协议: 可以用成熟 RPC 框架(Dubbo/Thrift)实现生产者和消费者与 Broker 的通信。
传递方式: 支持推拉结合。纯推模式消息实时但可能压垮消费者。纯拉模式消费者自控但需要轮询。实际中一般用长轮询 -- 消费者发起请求,Broker 有消息就直接返回,没有就等一会儿再返回或超时,兼顾实时性和资源消耗。Kafka 和 RocketMQ 都支持长轮询。
可靠性: 主从复制、集群模式保证高可用。消息确认机制保证不丢消息(消费者确认后才删除)。
高性能: 参考 Kafka 的批量操作、顺序写入、零拷贝。
功能扩展: 事务消息、延迟消息、顺序消息、消息堆积处理、重复消费处理、重平衡、集群数据同步等。
推模式还是拉模式
推模式: 消费者和 Broker 建 TCP 长连接或注册回调,有消息就推。优点是实时性好、消费者简单。缺点是如果生产速度大于消费速度,消息堆积在消费端,可能压垮消费者。
拉模式: 消费者定期轮询。优点是消费者完全自控速度和数量,避免堆积。缺点是轮询有压力、实时性差。
长轮询(最佳实践): 结合两者优点。消费者向 Broker 发长轮询请求,有消息直接返回,没有就等待一段时间,等待过程中有新消息再返回,超时则结束等下次轮询。
还有一个实际限制:有些生产环境只允许单向通信,这时候只能用拉模式(长轮询),推模式的双向连接不行。
十、数据库热点更新的连锁反应
连接池满的根因
线上频繁出现数据库连接池满的报警,报错信息中显示活跃连接数已达到 maxActive。排查发现不是流量异常,也不是慢 SQL,而是一条简单的 update 语句因为热点更新导致的锁等待把连接池耗尽了。
那条 SQL 虽然用了乐观锁(lock_version = lock_version + 1),看起来应该没有锁冲突的问题。但实际上乐观锁虽然在应用层不加锁,InnoDB 的 update 语句本身是要加行级锁的。当并发冲突很大时,多个 update 排队等锁,等的过程就占着数据库连接。排队的事务一多,连接池就被耗尽了。
解决思路有四种:
- 缓存更新 -- 热点数据放 Redis,减少对数据库的直接更新
- 异步更新 -- 削峰填谷,把高并发更新变成批量定时更新
- 数据拆分 -- 把热点数据分散到不同库表,减少单行冲突
- 合并更新 -- 10 条加积分的请求算出总数一次性加上去
根据实际业务场景选择了合并更新 -- 10 分钟汇总一次,一次更新用户积分表。损失了一定的实时性,但连接池问题彻底解决。
十一、MQ 订单关闭的陷阱
为什么不建议用 MQ 延迟消息实现订单关闭
MQ 延迟消息看上去是订单关闭的完美方案 -- 下单时发一条延迟消息,到期后消费者关单。但在订单量大的场景下问题不少:
资源占用与成本: 每个订单都创建一个延迟消息,大量消息积压在 MQ 中,云服务按量计费的话成本可观。
延迟时间限制: 不是所有 MQ 都支持延迟消息。RocketMQ 开源版只支持 1s 5s 10s 30s 1m 2m 3m ... 2h 这几个固定档位。B 类采购订单的超时时间可能很长,超出支持范围。
可靠性风险: MQ 不能做到 100% 不丢消息,极端情况下延迟消息可能丢失。
大量无效消息(最关键): 大部分订单会在超时前完成支付或被用户取消。这意味着大部分延迟消息在到期后发现订单已经不需要关闭了 -- 纯属浪费。
精确性问题: 消息投递延迟是常见的,关闭操作的时间可能不够精确。
所以在订单量大的场景下,定时任务反而是更务实的选择。阿里内部的超时中心(TOC)就是基于定时任务实现的。
MQ 不该用的场景要用定时任务兜底
即使选择了 MQ 方案,也建议同时保留一个兜底的定时任务。定时任务扫描所有到期未关闭的订单做最终处理,防止 MQ 丢消息导致的遗漏。双保险总比单保险强。
定时任务的时间不精准问题,可以通过几种方式优化:
- 缩短扫描周期 -- 从 1 分钟改为 10 秒
- 分片任务 -- 多台机器并行扫描不同分片
- 多线程消费 -- 扫描出来的订单用线程池并行处理
- 生产者-消费者模式 -- 扫描线程把订单号放入内存队列,消费线程处理关单逻辑
- 时间轮 -- 在扫描后把订单放入内存时间轮做精确延迟执行
这些优化组合起来,可以把定时任务的关单延迟控制在秒级。
十二、重复消费与幂等控制
消息重复消费的处理
在分布式系统中没法保证消息不重复投递。解决方案是在消费端做好幂等控制。
对于消息的重复消费,最常见的方式是通过消息中定义的幂等号做防重判断。这个幂等号应该是约定好的业务字段(比如订单号),而不是 MQ 的 msg_id -- 因为发送者重复发送多次消息会产生不同的 msg_id,但消息内容相同。
对于重复下单场景,幂等号的生成可以用 Token 机制。当用户访问页面时请求后端获取 Token,后续操作中带上这个 Token。如果页面没有刷新,Token 不变,就可以做防重检测。
有了唯一的幂等字段之后,就可以基于一锁、二判、三更新来做幂等控制。但别忘了前面讲过的坑 -- 事务粒度不能大于锁粒度。
Token 的并发安全
Token 校验时有一个常被忽略的细节:高并发下可能多个线程同时 get 到同一个 Token 并通过校验。解决办法有三种:
- 用 Redis 事务把 get 和 del 包在一起
- 用 Lua 脚本原子执行
- 直接用
DEL命令 -- 返回 1 表示存在且已删除(推荐,最简单)
消息乱序的应对策略
下单过程中支付消息和发货消息本该按顺序到达,但网络延迟可能导致发货消息先于支付消息被消费。处理乱序有四种思路:
顺序消息: 发送时按顺序投递到同一个 Partition,单消费者串行消费。最直接但吞吐量受限。
前置状态判断: 消息体中增加 beforeStatus 字段。消费时判断当前状态是否等于 beforeStatus,不一致就让消息处理失败等 MQ 重投。要求消息能推进单据状态且状态单向流转。
序列号重排: 消息中附递增序列号,消费端缓冲后按序号排序再处理。增加复杂度。
本地事件表: 收到消息后做基本校验,校验成功就转成内部事件存数据库(状态设为待处理),立刻返回成功。然后异步线程处理事件,处理失败不改状态、执行次数 +1。同时起定时任务扫描未成功的事件重试。这样消息存储后就不怕丢失,也不怕乱序 -- 因为可以在处理时按业务规则排序。达到一定重试次数报警人工跟进。
进一步优化:写入事件表的同时在 Redis 中记录业务单号和事件主键 ID,后续新消息来时检查是否有同单号的待处理消息,有的话直接从 Redis 拿到主键 ID 查询处理。减少定时任务的延迟。
数据库热点更新的四种出路
热点行更新是电商系统的常见痛点 -- 无论是库存扣减、余额变更还是积分累加,归根结底都是多个事务争抢同一行数据的行级锁。解决思路可以归纳为四类:排队(Redis 单线程 / 数据库内核排队优化)、拆分(大库存拆小库存降低锁粒度)、合并(多条 update 聚合成一条批量执行)、异步化(把 update 转 insert 写流水再异步汇总)。具体选哪种取决于业务对实时性和准确性的要求。
小结
订单与支付领域的核心挑战可以归结为一句话:在高并发下保证共享资源操作的原子性和有序性。
秒杀防超卖靠 Redis Lua 脚本的原子操作加异步落库,支付幂等靠状态机加渠道单号比对,防重复提交靠 Token 加 SETNX,状态并发靠乐观锁互斥,分库分表靠买家 ID 加基因法。方案各有侧重,但底层逻辑一脉相承:要么让操作变成原子的,要么让并发变成串行的,要么引入对账做最终兜底。
没有银弹,只有组合拳。