9:00,促销开始。CDN 撑住了商品页,但结账出了问题:两位顾客买下了同一件夹克的最后一件,超时后的一次重试在同一张卡上留下两笔支付冻结,而仓库因为一个事件丢失,晚了一小时才得知一笔订单。
这些都不是容量问题,而是边界问题:哪些数据归谁所有,以及哪些步骤必须在顾客等待期间完成。
本文介绍一套适合中型商店的真实电商架构:组件与数据归属、结账路径的逐步流程、之后的订单流水线、峰值流量,以及平台的架构图。
从顾客的行为出发
五条用户旅程涵盖了商店的大部分行为,它们的要求差别很大:
| 旅程 | 路径上的组件 | 哪些数据可以过期 |
|---|---|---|
| 浏览 | CDN、网关、Catalog | 缓存页面上的价格和库存,可过期数分钟 |
| 搜索 | 搜索索引 | 变更的商品,可过期数秒 |
| 购物车 | Cart | 顾客刚修改的内容不可过期 |
| 结账 | Checkout、Inventory、支付服务商、Orders | 不可过期 |
| 订单追踪 | Orders、Fulfillment | 物流状态,可过期数分钟 |
浏览和搜索承载了大部分请求,能容忍数据过期;结账承载的请求很少,却一点也不能容忍。架构的大部分设计都源于这一划分。
各组件及其拥有的数据
在边缘,CDN 缓存页面和图片,API 网关(或每种客户端各一个 backend for frontend)负责认证、限流并路由其余请求。网关之后的每个服务都拥有自己的数据:
| 服务 | 拥有的数据 | 典型存储 | 一致性要求 |
|---|---|---|---|
| Catalog | 商品、规格、价格 | Postgres,经缓存读取 | 最终一致;结账时重新读取价格 |
| Search | 带分面的商品索引 | OpenSearch 或 Elasticsearch | 最终一致,随目录变更更新 |
| Cart | 每位顾客的购物车条目 | 带 TTL 的 Redis | 每位顾客读己之写 |
| Checkout | 会话:合计金额、预留与支付 ID | Postgres | 会话内强一致 |
| Inventory | 每个 SKU 的库存、预留 | Postgres | 强一致:绝不超卖 |
| Orders | 订单、状态历史、outbox | Postgres | 强一致:每个订单只有一个写入方 |
| 支付服务提供商(PSP) | 授权、扣款、退款 | PSP 自身 | 以 PSP 的记录为准 |
| Fulfillment | 发货单、运单号 | WMS 或 3PL | 最终一致,由事件驱动 |
其他服务使用所有者的 API 或事件,绝不直接访问它的表。库存和订单需要事务;购物车和搜索索引可以滞后,甚至丢失一次写入,也不会造成实际损害。
结账路径的逐步流程
结账是唯一一个顾客需要同时等待多个服务的流程:
重新计算购物车价格。 Checkout 读取购物车,从目录获取当前价格,并在服务端计算税费、运费和折扣。永远不要相信浏览器传来的合计金额。
带过期时间预留库存,这里是 15 分钟:
-- One transaction: take the stock, then record the hold UPDATE stock SET reserved = reserved + 2 WHERE sku = 'JKT-042-M' AND on_hand - reserved >= 2; -- 0 rows: out of stock, stop INSERT INTO reservations (checkout_id, sku, qty, expires_at) VALUES ('chk_41a', 'JKT-042-M', 2, now() + interval '15 minutes');两位顾客抢最后一件夹克时,PostgreSQL 默认的 Read Committed 隔离级别会让第二个 update 等待,并针对新行重新检查它的 WHERE 条件,因此其中一人会得到 0 行。清理任务会把过期的预留归还库存。使用托管支付页面时,要让两边的时钟对齐:Stripe Checkout 会话默认持续 24 小时,除非你设置更短的过期时间(最少 30 分钟)或自行让它过期;
checkout.session.expired会通知你释放商品。用幂等键创建支付。 Checkout 向 PSP 请求一笔服务端合计金额的支付,采用手动扣款,并在调用前保存幂等键。超时后用同一个键重试:Stripe 会返回已保存的响应,而不是创建第二笔支付。它的键最长 255 个字符,24 小时后可能被清除。
授权。 先延长库存预留,避免它在支付中途失效。顾客在 PSP 的输入框中填写卡信息,所以卡号不会到达你的服务器,发卡行还可能要求 3-D Secure。成功后资金被冻结:采用手动扣款的 Stripe 支付会进入
requires_capture状态,并发出payment_intent.amount_capturable_updated。要信任这个 webhook,而不是浏览器的重定向;参见支付网关集成图。下单。 Orders 创建状态为
placed的订单,并在同一事务中把OrderPlaced写入 outbox 表。随后 Checkout 通知 Inventory 把预留转为分配。发货时扣款。 Fulfillment 发货后,Orders 执行扣款。截至撰写时(2026 年 11 月),Stripe 文档指出,线上银行卡授权通常有效 7 天;商户发起的 Visa 交易为 5 天(准确地说是 4 天 18 小时);之后资金释放,支付被取消。大多数银行卡支付只允许扣款一次,部分扣款会释放其余金额。
如果在订单生成之前某个步骤失败,就按相反顺序撤销前面的步骤:取消授权,释放预留。这就是一个以 Checkout 为编排者的 Saga。
资金、库存与订单状态在哪里变化
| 订单状态 | 设置时机 | 支付 | 库存 |
|---|---|---|---|
| 结账中 | 库存已预留 | 已创建,未授权 | 预留 15 分钟 |
| Placed | PSP 通知授权成功 | 资金已冻结 | 已分配给订单 |
| Shipped | Fulfillment 发货 | 已扣款 | 从现有库存中扣除 |
| Canceled | 发货前取消 | 授权已取消 | 已释放 |
| Returned | 收到退货 | 已退款 | 检验后重新入库 |
难处理的情况都在各行之间。如果授权在预留过期之后才到达,库存还有的话就重新预留;否则取消授权并告知顾客。如果缺货预订(backorder)超过 7 天的期限,就在首次发货时扣收全款,或在 PSP 提供时使用延长授权(据 Stripe 称,某些卡组织最长 30 天),或者先收款,再退还无法发出的部分。有些支付方式,如 ACH 和 iDEAL,根本无法单独扣款。
订单生成之后
placed 之后的一切都异步运行。中继进程把 OrderPlaced 从 outbox 发布到 order-events 主题,这样在提交和消息代理之间不会丢事件(参见 transactional outbox 模式)。Fulfillment 拣货、打包并发货,然后发布 OrderShipped,触发扣款。Notifications 发送邮件;Analytics 把每个事件加载进数据仓库。
投递保证至少一次,所以消费者要按事件 ID 去重。以订单 ID 作为主题的键,可以让每个订单的事件保持顺序。
微服务架构图则是在 OrderPlaced 之后才向银行卡扣款。这适合不需要顾客参与的支付;3-D Secure 需要顾客在场,所以这套设计在结账中完成授权。
扛住峰值流量
假设促销一小时内有 7,200 笔订单,转化率 2%,每次会话 10 次页面浏览:共 360,000 次会话和 360 万次页面浏览,也就是每秒约 1,000 次页面浏览,对应每秒 2 笔订单。两部分各需要自己的保护:
- 在边缘缓存商品目录。 使用
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=60,CDN 会把商品页提供 5 分钟,之后在后台重新获取的同时再多提供最多 1 分钟,这正是 RFC 5861 的定义。命中率 90% 时,源站每秒只看到 100 次页面浏览,而不是 1,000 次。价格变动时清除缓存;结账本来也会重新核对。 - 在结账前排队。 虚拟等候室按 Inventory 和 PSP 能承受的速率放行顾客,就像票务预订系统设计中那样。
- 保护热门 SKU。 同一个 SKU 的预留只更新一行,而且一次只能处理一个。每个事务持有锁 2 ms 的话,这一行的上限约为每秒 500 次预留。保持事务简短且不含 PSP 调用,把特价爆款的库存拆到多行,并限制每笔订单的数量。
- 提前了解 PSP 的速率限制,在促销开始之前。
比这更简单地起步
很多商店不该把这些全部自己搭建。Shopify 或 BigCommerce 这样的托管平台会替你运行目录、购物车和结账;这时你的图展示的是围绕它的应用和 webhook。
自建时,往往最好从模块化单体起步:同样的边界,作为一个可部署单元内的模块。2019 年,Shopify 的工程师介绍了他们如何围绕订单、配送、库存和计费重新组织最大的 Ruby on Rails 代码库之一,而不是把它拆成微服务。当某个模块需要独立扩展或独立发布时,再把它拆成服务;这张图依然适用。
电商平台架构图
图表内容
- Shopper
- CDN
- API gateway / BFF
- Catalog
- Search
- Cart
- Checkout
- Orders
- Inventory
- Payment provider (PSP)
- order-events
- Fulfillment
- Notifications
- Analytics warehouse
- HTTPS
- misses, API
- GET /products
- GET /search
- POST /cart/items
- POST /checkout
- GET /orders/{id}
- product changes
- read cart
- reserve, 15 min
- authorize + idempotency key
- place order
- capture on OrderShipped
- OrderPlaced
- placed, shipped
- all events
这张图的读法如下:
- 请求从左上角进入:顾客、CDN,然后是网关,网关把每条旅程路由到一个服务。
- 蓝色是浏览路径。 虚线边表示搜索索引追上商品变更。
- 琥珀色是结账路径,顾客在这里等待:读取购物车、预留库存、授权并下单。
- 支付服务商会收到两次调用:结账时授权,以及
OrderShipped时扣款(青色虚线)。 - 紫色虚线边是事件,从 outbox 经 order-events 流向 Fulfillment、Notifications 和 Analytics。
- 每个服务都标明自己的存储,Markdown 块列出具体数字。
把电商架构图画清楚
- 在网关的边上标注路由,如
GET /products,让读者能把页面对应到服务。 - 在每个服务上标明存储。 共享数据库不应该被忽略。
- 实线表示等待,虚线表示稍后。 顾客等待的调用应该与事件看起来不同。
- 在边上写明时间。 预留时长和扣款触发点最需要评审。
- 把授权和扣款画成两条边。 只有一个指向 PSP 的箭头,会掩盖关键的时间点。
- 不要画: Pod、区域、税务和反欺诈供应商,以及后台管理。
在 Archboard 中构建
- 用 Google 或 GitHub 登录并创建一个图表。
- 从 C4 Model 包添加一个人员,再从其下方的云包添加一个 CDN 和一个 API 网关。
- 从 C4 Model 包按一列添加五个容器节点:Catalog、Search、Cart、Checkout 和 Orders,每个的描述中写明它的存储。
- 添加一个用于 Inventory 的容器、一个用于支付服务商的外部系统,以及一个来自数据包、用于 order-events 的主题。在它们右侧,为 Fulfillment 和 Notifications 添加容器节点,再从数据包添加一个数据仓库。
- 用带路由标签的连线把网关连到这五个服务。结账调用用实线,商品变更、扣款和事件用虚线。
- 从基础包添加一个写有具体数字的 Markdown 块,再添加文本便签用作图例和关键规则。
如果你的服务通过 Compose 文件运行,Docker Compose 转图表工具可以生成方框草稿;微服务架构模板是另一个起点。这个示例共用 15 个图表节点,在 Free 套餐的限制之内。
常见问题
电商架构的主要组件有哪些? CDN、API 网关、目录、搜索、购物车、结账、库存和订单等服务、支付服务商,以及 Fulfillment、Notifications 和 Analytics。托管平台把其中大部分打包在一起。
网店应该使用微服务吗? 默认不需要。边界相同的模块化单体更容易运维,Shopify 的核心系统就选择了它。当某个模块需要独立扩展或独立发布时,再把它拆成服务。
如何防止库存超卖? 对每个 SKU 用一次原子的条件更新来预留库存,给每次预留设置过期时间,并且只在订单生成时才分配。永远不要根据缓存页面来判断是否有货。
网店应该在什么时候扣款? 对实物商品,通常在发货时扣款,这样只对实际发出的商品收费。线上银行卡授权通常有效 7 天。在 Stripe 上,Klarna 允许 28 天,PayPal 为 10 天,Stripe 会尝试把它延长到 20 天。
电商网站如何应对流量高峰? 它们从 CDN 提供商品目录,在结账前让顾客排队,并保护热门 SKU 和支付服务商。要把结账容量与页面浏览量分开规划。