电商下单系统设计详解

电商下单系统设计

电商下单系统 是系统设计面试里的经典题:它看起来只是“用户点一下提交订单”,背后却串起商品、价格、优惠、库存、订单、支付、履约、消息、风控、对账等多个子系统。

面试官真正想看的,不是你能不能画出很多服务,而是你能不能把关键矛盾讲清楚:如何防止重复下单,如何避免库存超卖,如何处理支付回调,如何保证订单状态最终一致,如何在大促高峰下削峰降级。

本文脉络:

  • 一、题目理解:先把边界讲清楚
  • 二、核心需求与非功能指标
  • 三、容量估算:下单系统到底有多大压力
  • 四、整体架构:同步链路短,异步链路长
  • 五、下单主流程:从提交订单到待支付
  • 六、库存设计:预占、确认、释放
  • 七、支付设计:支付单、回调、对账
  • 八、订单状态机:用状态流转保护一致性
  • 九、数据模型与分库分表
  • 十、幂等、一致性与补偿
  • 十一、高并发与高可用设计
  • 十二、常见追问

一、题目理解:先把边界讲清楚

面试里听到“设计电商下单系统”,不要一上来就画架构图。先问清楚范围:

问题 为什么要问
是普通电商,还是秒杀/大促? 秒杀会把库存热点、限流、排队放到第一优先级
是否包含购物车? 购物车是下单前置系统,不一定属于核心下单链路
是否支持多商家、多仓、多包裹? 会影响订单拆单、库存路由和履约复杂度
是否要讲支付? 支付是下单系统最关键的异步一致性来源
是否要求强一致库存? 决定用数据库扣减、Redis 预占,还是混合方案

本文按“通用电商下单系统”来设计,覆盖普通商品下单、大促削峰和支付回调,但不展开推荐、搜索、售后、财务结算等独立系统。

二、核心需求与非功能指标

功能需求

一个完整下单系统至少要支持:

功能 说明
提交订单 用户选择商品、地址、优惠、支付方式后提交
价格计算 校验商品价格、优惠券、运费、税费、活动规则
库存预占 下单时锁定库存,防止支付前被别人买走
创建订单 生成订单主表、订单明细、支付单、状态流水
支付回调 接收支付渠道回调,推进订单状态
超时关单 待支付订单超时后取消,释放库存和优惠
异步履约 支付成功后通知仓储、配送、通知、积分等系统

非功能需求

指标 建议目标
可用性 核心下单链路 99.99%
延迟 普通下单 P99 < 300ms;大促可接受排队
一致性 不能超卖,不能重复支付,订单状态最终一致
可扩展 支持多仓、多商家、优惠活动、国际化支付扩展
可观测 每笔订单能追踪状态变化、消息投递和外部回调

三、容量估算:下单系统到底有多大压力

假设一个中大型电商平台:

指标 假设值
DAU 2000 万
日订单量 300 万
下单高峰集中度 20% 订单发生在 1 小时内
峰值下单 QPS 约 167 QPS,活动放大 10 倍约 1700 QPS
读写比例 商品详情/购物车读远高于下单写

普通电商下单 QPS 往往没有搜索、商品详情页那么夸张,但它的难点在于写链路长、状态多、外部依赖多、错误恢复复杂

高峰期真正危险的不是平均 QPS,而是:

  • 热门 SKU 的库存扣减成为单点热点
  • 支付回调、取消、超时关单并发修改同一订单
  • MQ 积压导致履约、通知、库存释放延迟
  • 用户重复点击、客户端重试、网关重试造成重复请求

所以面试回答要从“吞吐量”尽快转到“状态一致性和故障恢复”。

四、整体架构:同步链路短,异步链路长

电商下单系统核心链路

核心原则是:同步链路只处理必须马上给用户结果的事情,其他事情全部异步化。

同步链路包括:

  1. 用户鉴权与风控基础校验。
  2. 购物车、商品、价格、优惠券校验。
  3. 库存预占。
  4. 创建订单和支付单。
  5. 返回支付参数或待支付订单。

异步链路包括:

  • 支付成功后通知履约。
  • 发送短信、App Push、邮件。
  • 写搜索索引、用户行为、积分、营销数据。
  • 超时关单与库存释放。
  • 对账、审计、数据分析。

这套拆法的好处是:下单接口不被非核心动作拖慢,下游故障也不会直接阻塞用户提交订单。

下单编排服务具体做什么

架构图里的“下单编排服务”不是把所有业务逻辑都塞进一个大泥球,而是承担同步下单链路的流程编排和一致性边界控制。它更像一个事务协调器:自己不负责维护所有领域数据,但负责按正确顺序调用各领域服务,并把关键结果固化成订单事实。

它内部通常可以拆成几个清晰的职责模块:

模块 主要职责 关键设计点
请求接入与幂等模块 解析下单请求,校验 requestId,防止重复提交 user_id + requestId 唯一约束;重复请求返回首次结果
用户与风控校验模块 校验用户状态、收货地址、黑名单、基础风控规则 强风控失败直接拒绝;弱风控可异步审计
商品与价格快照模块 查询 SKU、实时价格、活动价、运费、税费 订单必须保存价格快照,不能依赖后续商品价格
优惠与权益锁定模块 校验优惠券、积分、会员权益,并临时锁定 下单失败要释放;支付成功后确认使用
库存预占模块 按 SKU 维度预占库存,生成库存预占记录 防超卖;失败后要回滚已预占的 SKU
订单落库模块 写订单主表、明细表、支付单、状态流水 尽量放在一个本地事务里完成
支付发起模块 创建支付单,向支付系统申请支付参数 支付单独立幂等;金额必须和订单金额一致
事件发布模块 写本地事件表或发送 OrderCreated 事件 推荐 Outbox,避免订单成功但消息丢失
异常补偿模块 处理部分成功、部分失败的回滚和补偿 释放库存、释放优惠券、关闭支付单

可以把下单编排服务理解成一条“闸门严格的流水线”:

  1. 先挡重复请求:同一个用户同一个 requestId 只能创建一次订单。
  2. 再算准价格:价格、优惠、运费都以服务端计算结果为准,并写入快照。
  3. 再锁住库存和权益:库存、优惠券、积分都先预占,避免支付过程中被别人使用。
  4. 再落订单事实:订单主表、明细、支付单、状态流水写入数据库。
  5. 最后发事件:让履约、通知、积分、数据分析等系统异步消费。

这里最容易出错的是“调用了很多下游服务,但中间某一步失败了”。因此下单编排服务必须明确每个步骤的失败处理:

失败点 处理方式
价格校验失败 直接返回价格变化或商品不可售
优惠券锁定失败 让用户重新选择优惠或无优惠下单
部分 SKU 库存预占成功,部分失败 释放已预占库存,返回库存不足
订单落库失败 释放库存和优惠,记录异常日志
支付单创建失败 订单保持待支付失败或直接取消,并释放资源
事件发送失败 依赖本地事件表重试投递,不能丢事件

工程实现上,下单编排服务一般会保持“薄领域、强编排”:

  • 商品、库存、优惠、支付仍由各自领域服务维护数据和规则。
  • 下单编排服务负责调用顺序、幂等边界、事务边界、补偿策略。
  • 关键状态必须落库,不能只依赖内存或远程调用结果。
  • 所有外部调用都要设置超时、重试上限和熔断策略。

如果面试官追问“为什么不让订单服务直接做所有事”,可以这样回答:订单服务可以作为下单编排的承载者,但不能吞掉所有领域职责。商品价格、库存、优惠、支付都有各自复杂规则,应该保持领域边界;下单侧只保存最终快照和状态事实。

五、下单主流程:从提交订单到待支付

一次普通下单可以拆成 8 步:

步骤 动作 关键点
1 用户提交订单 客户端带 requestId 或幂等 Key
2 网关限流鉴权 防刷、防重复提交
3 查询商品与价格 以服务端价格为准,生成价格快照
4 校验优惠券 防止过期、重复使用、越权使用
5 预占库存 按 SKU 维度锁定库存
6 创建订单 订单主表、明细表、状态流水同事务落库
7 创建支付单 一单一支付单,支付单也要幂等
8 返回支付参数 用户跳转收银台或唤起支付渠道

下单接口不要相信客户端传来的价格、库存状态和优惠金额。客户端只能传“用户选择了什么”,最终价格必须由服务端重新计算。

六、库存设计:预占、确认、释放

库存是下单系统最容易被追问的部分。

为什么不能只在支付成功后扣库存?

如果只在支付成功后扣库存,会出现两个问题:

问题 例子
支付后无货 用户支付成功,库存才发现已经卖完
体验差 用户下单时看到可买,但支付后被取消

所以常见做法是:下单时预占库存,支付成功后确认扣减,超时未支付则释放库存。

库存三段式

阶段 动作 说明
预占 available - nreserved + n 创建待支付订单时执行
确认 reserved - nsold + n 支付成功后执行
释放 reserved - navailable + n 订单取消或超时未支付

数据库里可以维护类似字段:

字段 含义
sku_id 商品 SKU
available_qty 可售库存
reserved_qty 已预占库存
sold_qty 已售库存
version 乐观锁版本号

预占库存的 SQL 可以这样设计:

UPDATE sku_stock
SET available_qty = available_qty - #{qty},
reserved_qty = reserved_qty + #{qty},
version = version + 1
WHERE sku_id = #{skuId}
AND available_qty >= #{qty};

这条 SQL 的关键是 available_qty >= #{qty}。更新影响行数为 1,说明预占成功;影响行数为 0,说明库存不足。

热点库存怎么办?

普通商品可以直接走数据库条件更新;大促热点 SKU 则需要更强的削峰:

方案 适用场景 风险
DB 条件更新 普通商品 热点 SKU 容易打爆单行
Redis 原子扣减 秒杀/大促 需要异步落库和补偿
库存分片 热门商品 复杂度更高,需要聚合可售量
排队令牌 极端活动 用户体验变成排队

面试里可以这样回答:普通下单用数据库条件更新保证准确;大促场景将热点 SKU 放入 Redis 令牌桶或库存分片,先拿到购买资格,再异步创建订单,最终仍要有库存对账。

七、支付设计:支付单、回调、对账

支付系统的核心原则是:订单和支付单分离。

对象 职责
订单 表示用户买了什么、履约到哪一步
支付单 表示本次支付请求、支付渠道流水、金额和回调状态

为什么要分离?

  • 一个订单可能有多次支付尝试。
  • 支付渠道回调可能重复。
  • 支付成功但订单更新失败,需要补偿。
  • 退款、部分退款、组合支付都需要独立支付记录。

支付回调处理流程

支付渠道回调通常不能只依赖“收到回调就改订单”。更安全的做法是:

  1. 校验签名,确认回调来自支付渠道。
  2. 用支付渠道流水号做幂等去重。
  3. 校验支付金额、币种、商户号、订单号。
  4. 更新支付单状态为 PAID
  5. 推进订单状态为 PAID
  6. 发送 PaymentPaid 事件给 MQ。
  7. 异步触发库存确认、履约、通知。

如果第 4 步成功,第 5 步失败,要通过补偿任务扫描“支付成功但订单未支付”的异常记录,继续推进订单状态。

为什么需要对账?

支付链路跨越外部渠道,不能假设回调永远可靠。需要每天或准实时对账:

对账类型 发现的问题
支付渠道账单 vs 支付单 渠道已扣款但系统未标记支付成功
支付单 vs 订单 支付成功但订单仍待支付
退款单 vs 渠道退款流水 系统已退款但渠道失败

对账不是锦上添花,而是支付系统最终一致性的最后防线。

八、订单状态机:用状态流转保护一致性

订单状态机

订单状态不能随便更新,必须通过状态机约束。

状态 含义
CREATED 订单创建中,尚未完全落库
WAIT_PAY 待支付,库存已预占
PAID 已支付,等待履约
FULFILLING 履约中,仓库出库或配送中
COMPLETED 已完成
CANCELLED 已取消,库存和优惠已释放
REFUNDING 退款或售后处理中

状态机的关键规则:

  • 正常链路只能单向推进。
  • 支付成功不能回到待支付。
  • 取消请求和支付回调并发到达时,以数据库状态机做最终裁决。
  • 每次状态变化都写订单状态流水。
  • MQ 消费者处理状态事件必须幂等。

典型并发冲突是:用户刚好超时取消,支付渠道又回调支付成功。处理方式不是靠“先后猜测”,而是用状态更新条件控制:

UPDATE orders
SET status = 'PAID',
paid_at = NOW()
WHERE order_id = #{orderId}
AND status = 'WAIT_PAY';

如果影响行数为 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_idorder_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 可以变成 PAIDCANCELLED,失败的一方进入补偿/退款流程。
大促下单如何抗峰值? 前置资格校验、限流排队、热点库存 Redis 化、异步创建订单、核心链路隔离。
为什么订单和支付单要分离? 一个订单可能多次支付尝试,支付回调可重复,退款和对账也需要独立支付流水。
下单接口能不能全异步? 普通电商通常同步返回订单和支付参数;秒杀可以异步排队,但要给用户明确排队状态。

总结

电商下单系统的设计主线可以压缩成一句话:

用幂等挡重复请求,用库存预占挡超卖,用订单状态机挡并发乱序,用 MQ 解耦长链路,用补偿和对账兜住最终一致。

面试时不要只说“订单服务、库存服务、支付服务、MQ”。真正加分的是讲清楚每个边界为什么存在:哪些必须同步,哪些可以异步;哪些必须强一致,哪些可以最终一致;失败以后系统如何恢复。