电商下单系统设计详解

电商下单系统 是系统设计面试里的经典题:它看起来只是“用户点一下提交订单”,背后却串起商品、价格、优惠、库存、订单、支付、履约、消息、风控、对账等多个子系统。
面试官真正想看的,不是你能不能画出很多服务,而是你能不能把关键矛盾讲清楚:如何防止重复下单,如何避免库存超卖,如何处理支付回调,如何保证订单状态最终一致,如何在大促高峰下削峰降级。
本文脉络:
- 一、题目理解:先把边界讲清楚
- 二、核心需求与非功能指标
- 三、容量估算:下单系统到底有多大压力
- 四、整体架构:同步链路短,异步链路长
- 五、下单主流程:从提交订单到待支付
- 六、库存设计:预占、确认、释放
- 七、支付设计:支付单、回调、对账
- 八、订单状态机:用状态流转保护一致性
- 九、数据模型与分库分表
- 十、幂等、一致性与补偿
- 十一、高并发与高可用设计
- 十二、常见追问
一、题目理解:先把边界讲清楚
面试里听到“设计电商下单系统”,不要一上来就画架构图。先问清楚范围:
| 问题 | 为什么要问 |
|---|---|
| 是普通电商,还是秒杀/大促? | 秒杀会把库存热点、限流、排队放到第一优先级 |
| 是否包含购物车? | 购物车是下单前置系统,不一定属于核心下单链路 |
| 是否支持多商家、多仓、多包裹? | 会影响订单拆单、库存路由和履约复杂度 |
| 是否要讲支付? | 支付是下单系统最关键的异步一致性来源 |
| 是否要求强一致库存? | 决定用数据库扣减、Redis 预占,还是混合方案 |
本文按“通用电商下单系统”来设计,覆盖普通商品下单、大促削峰和支付回调,但不展开推荐、搜索、售后、财务结算等独立系统。
二、核心需求与非功能指标
功能需求
一个完整下单系统至少要支持:
| 功能 | 说明 |
|---|---|
| 提交订单 | 用户选择商品、地址、优惠、支付方式后提交 |
| 价格计算 | 校验商品价格、优惠券、运费、税费、活动规则 |
| 库存预占 | 下单时锁定库存,防止支付前被别人买走 |
| 创建订单 | 生成订单主表、订单明细、支付单、状态流水 |
| 支付回调 | 接收支付渠道回调,推进订单状态 |
| 超时关单 | 待支付订单超时后取消,释放库存和优惠 |
| 异步履约 | 支付成功后通知仓储、配送、通知、积分等系统 |
非功能需求
| 指标 | 建议目标 |
|---|---|
| 可用性 | 核心下单链路 99.99% |
| 延迟 | 普通下单 P99 < 300ms;大促可接受排队 |
| 一致性 | 不能超卖,不能重复支付,订单状态最终一致 |
| 可扩展 | 支持多仓、多商家、优惠活动、国际化支付扩展 |
| 可观测 | 每笔订单能追踪状态变化、消息投递和外部回调 |
三、容量估算:下单系统到底有多大压力
假设一个中大型电商平台:
| 指标 | 假设值 |
|---|---|
| DAU | 2000 万 |
| 日订单量 | 300 万 |
| 下单高峰集中度 | 20% 订单发生在 1 小时内 |
| 峰值下单 QPS | 约 167 QPS,活动放大 10 倍约 1700 QPS |
| 读写比例 | 商品详情/购物车读远高于下单写 |
普通电商下单 QPS 往往没有搜索、商品详情页那么夸张,但它的难点在于写链路长、状态多、外部依赖多、错误恢复复杂。
高峰期真正危险的不是平均 QPS,而是:
- 热门 SKU 的库存扣减成为单点热点
- 支付回调、取消、超时关单并发修改同一订单
- MQ 积压导致履约、通知、库存释放延迟
- 用户重复点击、客户端重试、网关重试造成重复请求
所以面试回答要从“吞吐量”尽快转到“状态一致性和故障恢复”。
四、整体架构:同步链路短,异步链路长
核心原则是:同步链路只处理必须马上给用户结果的事情,其他事情全部异步化。
同步链路包括:
- 用户鉴权与风控基础校验。
- 购物车、商品、价格、优惠券校验。
- 库存预占。
- 创建订单和支付单。
- 返回支付参数或待支付订单。
异步链路包括:
- 支付成功后通知履约。
- 发送短信、App Push、邮件。
- 写搜索索引、用户行为、积分、营销数据。
- 超时关单与库存释放。
- 对账、审计、数据分析。
这套拆法的好处是:下单接口不被非核心动作拖慢,下游故障也不会直接阻塞用户提交订单。
下单编排服务具体做什么
架构图里的“下单编排服务”不是把所有业务逻辑都塞进一个大泥球,而是承担同步下单链路的流程编排和一致性边界控制。它更像一个事务协调器:自己不负责维护所有领域数据,但负责按正确顺序调用各领域服务,并把关键结果固化成订单事实。
它内部通常可以拆成几个清晰的职责模块:
| 模块 | 主要职责 | 关键设计点 |
|---|---|---|
| 请求接入与幂等模块 | 解析下单请求,校验 requestId,防止重复提交 |
user_id + requestId 唯一约束;重复请求返回首次结果 |
| 用户与风控校验模块 | 校验用户状态、收货地址、黑名单、基础风控规则 | 强风控失败直接拒绝;弱风控可异步审计 |
| 商品与价格快照模块 | 查询 SKU、实时价格、活动价、运费、税费 | 订单必须保存价格快照,不能依赖后续商品价格 |
| 优惠与权益锁定模块 | 校验优惠券、积分、会员权益,并临时锁定 | 下单失败要释放;支付成功后确认使用 |
| 库存预占模块 | 按 SKU 维度预占库存,生成库存预占记录 | 防超卖;失败后要回滚已预占的 SKU |
| 订单落库模块 | 写订单主表、明细表、支付单、状态流水 | 尽量放在一个本地事务里完成 |
| 支付发起模块 | 创建支付单,向支付系统申请支付参数 | 支付单独立幂等;金额必须和订单金额一致 |
| 事件发布模块 | 写本地事件表或发送 OrderCreated 事件 |
推荐 Outbox,避免订单成功但消息丢失 |
| 异常补偿模块 | 处理部分成功、部分失败的回滚和补偿 | 释放库存、释放优惠券、关闭支付单 |
可以把下单编排服务理解成一条“闸门严格的流水线”:
- 先挡重复请求:同一个用户同一个
requestId只能创建一次订单。 - 再算准价格:价格、优惠、运费都以服务端计算结果为准,并写入快照。
- 再锁住库存和权益:库存、优惠券、积分都先预占,避免支付过程中被别人使用。
- 再落订单事实:订单主表、明细、支付单、状态流水写入数据库。
- 最后发事件:让履约、通知、积分、数据分析等系统异步消费。
这里最容易出错的是“调用了很多下游服务,但中间某一步失败了”。因此下单编排服务必须明确每个步骤的失败处理:
| 失败点 | 处理方式 |
|---|---|
| 价格校验失败 | 直接返回价格变化或商品不可售 |
| 优惠券锁定失败 | 让用户重新选择优惠或无优惠下单 |
| 部分 SKU 库存预占成功,部分失败 | 释放已预占库存,返回库存不足 |
| 订单落库失败 | 释放库存和优惠,记录异常日志 |
| 支付单创建失败 | 订单保持待支付失败或直接取消,并释放资源 |
| 事件发送失败 | 依赖本地事件表重试投递,不能丢事件 |
工程实现上,下单编排服务一般会保持“薄领域、强编排”:
- 商品、库存、优惠、支付仍由各自领域服务维护数据和规则。
- 下单编排服务负责调用顺序、幂等边界、事务边界、补偿策略。
- 关键状态必须落库,不能只依赖内存或远程调用结果。
- 所有外部调用都要设置超时、重试上限和熔断策略。
如果面试官追问“为什么不让订单服务直接做所有事”,可以这样回答:订单服务可以作为下单编排的承载者,但不能吞掉所有领域职责。商品价格、库存、优惠、支付都有各自复杂规则,应该保持领域边界;下单侧只保存最终快照和状态事实。
五、下单主流程:从提交订单到待支付
一次普通下单可以拆成 8 步:
| 步骤 | 动作 | 关键点 |
|---|---|---|
| 1 | 用户提交订单 | 客户端带 requestId 或幂等 Key |
| 2 | 网关限流鉴权 | 防刷、防重复提交 |
| 3 | 查询商品与价格 | 以服务端价格为准,生成价格快照 |
| 4 | 校验优惠券 | 防止过期、重复使用、越权使用 |
| 5 | 预占库存 | 按 SKU 维度锁定库存 |
| 6 | 创建订单 | 订单主表、明细表、状态流水同事务落库 |
| 7 | 创建支付单 | 一单一支付单,支付单也要幂等 |
| 8 | 返回支付参数 | 用户跳转收银台或唤起支付渠道 |
下单接口不要相信客户端传来的价格、库存状态和优惠金额。客户端只能传“用户选择了什么”,最终价格必须由服务端重新计算。
六、库存设计:预占、确认、释放
库存是下单系统最容易被追问的部分。
为什么不能只在支付成功后扣库存?
如果只在支付成功后扣库存,会出现两个问题:
| 问题 | 例子 |
|---|---|
| 支付后无货 | 用户支付成功,库存才发现已经卖完 |
| 体验差 | 用户下单时看到可买,但支付后被取消 |
所以常见做法是:下单时预占库存,支付成功后确认扣减,超时未支付则释放库存。
库存三段式
| 阶段 | 动作 | 说明 |
|---|---|---|
| 预占 | available - n,reserved + n |
创建待支付订单时执行 |
| 确认 | reserved - n,sold + n |
支付成功后执行 |
| 释放 | reserved - n,available + n |
订单取消或超时未支付 |
数据库里可以维护类似字段:
| 字段 | 含义 |
|---|---|
sku_id |
商品 SKU |
available_qty |
可售库存 |
reserved_qty |
已预占库存 |
sold_qty |
已售库存 |
version |
乐观锁版本号 |
预占库存的 SQL 可以这样设计:
UPDATE sku_stock |
这条 SQL 的关键是 available_qty >= #{qty}。更新影响行数为 1,说明预占成功;影响行数为 0,说明库存不足。
热点库存怎么办?
普通商品可以直接走数据库条件更新;大促热点 SKU 则需要更强的削峰:
| 方案 | 适用场景 | 风险 |
|---|---|---|
| DB 条件更新 | 普通商品 | 热点 SKU 容易打爆单行 |
| Redis 原子扣减 | 秒杀/大促 | 需要异步落库和补偿 |
| 库存分片 | 热门商品 | 复杂度更高,需要聚合可售量 |
| 排队令牌 | 极端活动 | 用户体验变成排队 |
面试里可以这样回答:普通下单用数据库条件更新保证准确;大促场景将热点 SKU 放入 Redis 令牌桶或库存分片,先拿到购买资格,再异步创建订单,最终仍要有库存对账。
七、支付设计:支付单、回调、对账
支付系统的核心原则是:订单和支付单分离。
| 对象 | 职责 |
|---|---|
| 订单 | 表示用户买了什么、履约到哪一步 |
| 支付单 | 表示本次支付请求、支付渠道流水、金额和回调状态 |
为什么要分离?
- 一个订单可能有多次支付尝试。
- 支付渠道回调可能重复。
- 支付成功但订单更新失败,需要补偿。
- 退款、部分退款、组合支付都需要独立支付记录。
支付回调处理流程
支付渠道回调通常不能只依赖“收到回调就改订单”。更安全的做法是:
- 校验签名,确认回调来自支付渠道。
- 用支付渠道流水号做幂等去重。
- 校验支付金额、币种、商户号、订单号。
- 更新支付单状态为
PAID。 - 推进订单状态为
PAID。 - 发送
PaymentPaid事件给 MQ。 - 异步触发库存确认、履约、通知。
如果第 4 步成功,第 5 步失败,要通过补偿任务扫描“支付成功但订单未支付”的异常记录,继续推进订单状态。
为什么需要对账?
支付链路跨越外部渠道,不能假设回调永远可靠。需要每天或准实时对账:
| 对账类型 | 发现的问题 |
|---|---|
| 支付渠道账单 vs 支付单 | 渠道已扣款但系统未标记支付成功 |
| 支付单 vs 订单 | 支付成功但订单仍待支付 |
| 退款单 vs 渠道退款流水 | 系统已退款但渠道失败 |
对账不是锦上添花,而是支付系统最终一致性的最后防线。
八、订单状态机:用状态流转保护一致性
订单状态不能随便更新,必须通过状态机约束。
| 状态 | 含义 |
|---|---|
CREATED |
订单创建中,尚未完全落库 |
WAIT_PAY |
待支付,库存已预占 |
PAID |
已支付,等待履约 |
FULFILLING |
履约中,仓库出库或配送中 |
COMPLETED |
已完成 |
CANCELLED |
已取消,库存和优惠已释放 |
REFUNDING |
退款或售后处理中 |
状态机的关键规则:
- 正常链路只能单向推进。
- 支付成功不能回到待支付。
- 取消请求和支付回调并发到达时,以数据库状态机做最终裁决。
- 每次状态变化都写订单状态流水。
- MQ 消费者处理状态事件必须幂等。
典型并发冲突是:用户刚好超时取消,支付渠道又回调支付成功。处理方式不是靠“先后猜测”,而是用状态更新条件控制:
UPDATE orders |
如果影响行数为 0,说明订单已经不是待支付,需要进入异常处理或退款流程。
九、数据模型与分库分表
核心表设计
| 表 | 关键字段 | 说明 |
|---|---|---|
orders |
order_id, user_id, status, total_amount, pay_deadline |
订单主表 |
order_items |
order_id, sku_id, qty, price_snapshot |
订单明细 |
payment_orders |
pay_id, order_id, channel, amount, status |
支付单 |
stock_reservations |
reservation_id, order_id, sku_id, qty, status |
库存预占记录 |
order_status_logs |
order_id, from_status, to_status, event_id |
状态流水 |
idempotent_records |
biz_key, request_hash, result |
幂等记录 |
这张表关系图里,orders 是中心事实表,其他表围绕它记录不同维度的事实:
order_items记录用户买了哪些 SKU,以及当时的价格快照。payment_orders记录每次支付尝试,方便处理重复回调、补单和对账。stock_reservations记录库存预占、确认、释放,方便失败补偿。order_status_logs记录订单状态从哪里来、到哪里去,是排障和审计依据。idempotent_records不一定直接外键关联订单,但会保存下单请求、支付回调、MQ 事件等业务幂等键。
分库分表
订单表数据量增长很快,一般按 user_id 或 order_id 分库分表:
| 分片键 | 优点 | 缺点 |
|---|---|---|
user_id |
用户查订单很方便 | 商家维度查询需要二级索引 |
order_id |
写入均匀,天然唯一 | 用户订单列表需要额外索引 |
seller_id |
商家后台查询方便 | 大商家容易热点 |
常见组合是:
- 订单主表按
user_id分片,优化用户侧查询。 - 订单 ID 使用雪花算法或号段服务生成,保证全局唯一。
- 商家后台、履约后台通过 ES/OLAP/异步宽表查询,不直接扫订单主库。
十、幂等、一致性与补偿
下单系统里,幂等不是一个点,而是一条链。
| 场景 | 幂等 Key |
|---|---|
| 用户提交订单 | user_id + request_id |
| 创建支付单 | order_id + pay_channel |
| 支付回调 | channel_trade_no |
| MQ 消费 | event_id |
| 库存预占 | order_id + sku_id |
| 取消订单 | order_id + cancel_event_id |
本地事务 + 事件表
创建订单时,建议在同一个数据库事务里写:
- 订单主表
- 订单明细
- 支付单
- 库存预占记录
- 订单状态流水
- 待发送事件表
事务提交后,由后台任务或 CDC 把事件投递到 MQ。这就是常见的 Outbox Pattern,可以避免“订单写库成功但 MQ 发送失败”的不一致。
Saga 补偿
下单链路跨多个服务,不适合用分布式大事务锁住所有资源。更常见的是 Saga:
| 正向动作 | 失败后的补偿 |
|---|---|
| 预占库存 | 释放库存 |
| 锁定优惠券 | 释放优惠券 |
| 创建支付单 | 关闭支付单 |
| 支付成功 | 确认库存、发履约 |
| 履约失败 | 发起退款或人工处理 |
补偿动作也必须幂等,因为它们可能被定时任务、MQ 重试、人工工具多次触发。
十一、高并发与高可用设计
1. 限流与排队
大促时不要让所有请求直接打到下单服务。
| 层级 | 策略 |
|---|---|
| CDN/WAF | 拦截恶意流量 |
| API 网关 | 用户维度、IP 维度、接口维度限流 |
| 活动层 | 资格校验、排队令牌、验证码 |
| 下单服务 | 线程池隔离、快速失败 |
对于秒杀,最有效的方式不是“数据库扛住所有请求”,而是让大部分请求在进入下单前就被过滤掉。
2. 缓存与读写隔离
下单前依赖大量读数据:商品、价格、活动、地址、运费模板。要尽量缓存:
- 商品详情和 SKU 信息缓存。
- 活动规则缓存。
- 用户地址本缓存。
- 运费模板缓存。
但库存和价格最终以服务端实时校验为准,不能只靠客户端或缓存值。
3. MQ 削峰
支付成功后的履约、通知、积分、营销、数据分析都可以走 MQ。
关键点:
- 生产者必须有重试和本地事件表。
- 消费者必须幂等。
- 失败消息进入重试队列和死信队列。
- 监控消费延迟、堆积量、失败率。
4. 降级策略
下单链路不能随便降级,但非关键依赖可以降级:
| 依赖 | 降级方式 |
|---|---|
| 推荐/营销 | 不展示推荐权益 |
| 积分 | 先下单,积分异步补发 |
| 通知 | 支付成功页展示结果,通知稍后补发 |
| 风控非强规则 | 低风险用户放行,高风险用户拦截 |
| 运费试算 | 使用保守运费模板 |
库存、订单落库、支付金额校验不能降级,否则会造成资损。
十二、常见追问
| 问题 | 回答要点 |
|---|---|
| 如何防止用户重复下单? | 客户端传 requestId,服务端用 user_id + requestId 做幂等记录,重复请求直接返回第一次结果。 |
| 如何避免库存超卖? | 普通场景用 DB 条件更新;大促场景用 Redis 原子扣减/库存分片/排队令牌,最终做库存对账。 |
| 支付成功但订单状态没更新怎么办? | 支付单先落成功,再通过补偿任务扫描“支付成功但订单未支付”的记录,继续推进订单状态。 |
| MQ 消息重复消费怎么办? | 消费端用 event_id 或业务唯一键做幂等,状态更新使用条件更新。 |
| 订单创建成功但 MQ 发送失败怎么办? | 用本地事件表或 CDC,订单和事件同事务落库,后台可靠投递。 |
| 超时关单和支付回调并发怎么办? | 使用状态机条件更新,只有 WAIT_PAY 可以变成 PAID 或 CANCELLED,失败的一方进入补偿/退款流程。 |
| 大促下单如何抗峰值? | 前置资格校验、限流排队、热点库存 Redis 化、异步创建订单、核心链路隔离。 |
| 为什么订单和支付单要分离? | 一个订单可能多次支付尝试,支付回调可重复,退款和对账也需要独立支付流水。 |
| 下单接口能不能全异步? | 普通电商通常同步返回订单和支付参数;秒杀可以异步排队,但要给用户明确排队状态。 |
总结
电商下单系统的设计主线可以压缩成一句话:
用幂等挡重复请求,用库存预占挡超卖,用订单状态机挡并发乱序,用 MQ 解耦长链路,用补偿和对账兜住最终一致。
面试时不要只说“订单服务、库存服务、支付服务、MQ”。真正加分的是讲清楚每个边界为什么存在:哪些必须同步,哪些可以异步;哪些必须强一致,哪些可以最终一致;失败以后系统如何恢复。