内容更新:
电商平台——想想 Amazon、阿里巴巴、Walmart 或 Shopify——是面试中可能被要求设计的最苛刻的分布式系统之一。它同时结合实时库存管理、金融交易处理、高流量搜索与发现,以及 ML 驱动的个性化,全都在严格正确性要求下——一个超卖的商品或一个丢失的订单直接转化为损失的收入和破碎的用户信任。本指南走完整个面试弧:需求、容量估算、API 与数据建模、高层架构,然后深入真正难的部分——结账、库存、支付、搜索、扩展和容错。
本文目录
- 微服务 + API 网关——每个服务拥有自己的数据存储;无跨服务直接 DB 访问。
- 钱用 SQL,目录用 NoSQL——订单/用户用 ACID;异构商品 schema 用文档存储。
- SQL 库存预留——结账时原子预留,数据库任务幂等回收过期库存;Redis 缓存不是库存账本。
- Kafka 解耦结账——用户得到即时确认;库存扣减、支付和通知在下游异步发生。
- CDC → Elasticsearch——搜索索引保持最终一致而不把写延迟耦合到索引。
- Cassandra 做归档——横向可扩展、高写吞吐用于历史订单记录。
- 处处幂等——支付键、事件消费者去重,和跨服务一致性的 Saga 模式。
用 API 网关后的微服务:订单和用户用 SQL(ACID),商品目录用 NoSQL(schema 灵活),历史订单归档用 Cassandra,购物车用 Redis,权威预留放在库存服务的 SQL 中,Kafka 把结账管道与下游处理解耦。Elasticsearch 经 CDC 喂的索引驱动搜索。在要紧处(钱、库存)保持一致,其他处拥抱最终一致。

第 1 步 — 澄清需求
画一个框前,大声界定问题。面试官奖励把一个宽得不可能的提示收窄成一个具体、可辩护的子集。电商很大,所以陈述你会和不会覆盖什么。
功能需求
- 用户注册、认证和资料管理。
- 商家入驻:店铺创建和商品管理(带媒体的 CRUD)。
- 商品搜索、浏览、购物车和愿望单。
- 结账和支付,产生一个持久订单。
- 个性化首页 feed 和订单历史。
- 订单生命周期跟踪(下单 → 已付 → 已发货 → 已送达)。
非功能需求
- 低延迟——搜索和页面加载 ~200 ms p99 内;结账应感觉即时。
- 高可用——目标 99.99%;高峰销售(Prime Day、双 11)期间宕机是灾难性的。
- 要紧处强正确性——库存绝不能超卖;支付绝不能双重扣款。
- 可扩展——挺过闪购期间 10–100× 流量尖峰。
- 持久性——一个已确认订单绝不丢失。
显式命名什么是范围外——推荐排序内部、欺诈检测、卖家分析套件——这样你能把时间花在核心事务路径上。说"我把支付处理当作经 webhook 的外部提供商"是一个界定范围的决定,不是缺口。
第 2 步 — 容量估算
动手估算:用 QPS、带宽与存储计算器调整流量假设,区分峰值吞吐与每日新增数据量。
粗略数字证明你的存储和扩展选择。假设一个有 1 亿日活(DAU)的大平台。
流量
- 若每 DAU 每天执行 ~10 次读动作(浏览/搜索):100M × 10 = 1B 读/天 ≈ 11,500 读/秒平均,5× 峰值因子约 ~58K 读/秒峰值。
- 若 1% DAU 每天下单:1M 订单/天 ≈ ~12 写/秒平均,但闪购能把那集中到几分钟——为突发 数千订单写/秒设计。
- 读写比大约 100:1,这呐喊"激进缓存并用读副本"。
存储
- 500M 商品 × 各 ~2 KB 元数据 ≈ 1 TB 目录数据(媒体前)。
- 商品图片/视频住在 CDN 后的对象存储(S3)——轻易 PB 级,挡在主 DB 外。
- 订单:1M/天 × 365 × ~1 KB ≈ ~365 GB/年热订单数据,线性增长——历史订单归档到 Cassandra 的原因。
你不需要确切数字——你需要正确的数量级和它们驱动的结论:100:1 读写比 → 缓存 + 副本;PB 级媒体 → 对象存储 + CDN;线性增长的订单 → 一个归档层。
第 3 步 — API 设计
通过 API 网关暴露干净的 REST 面。几个代表性端点:
# 目录与搜索
GET /api/products?q=shoes&category=men&page=2
GET /api/products/{productId}
# 购物车(购买意向;结账时预留)
POST /api/cart/items { productId, qty }
DELETE /api/cart/items/{id}
# 结账——经客户端提供的键幂等
POST /api/orders
Idempotency-Key: "a1b2-c3d4"
{ cartId, addressId, paymentMethodId }
GET /api/orders/{orderId}
两个赢得加分的细节:结账端点携带一个 Idempotency-Key,使重试请求(不稳定移动网络、双击)绝不创建重复订单;搜索支持 cursor/offset 分页,因为没有客户端应取一个无界结果集。
第 4 步 — 核心服务
平台被分解成聚焦的微服务,每个拥有自己的数据存储。服务读时经 API 网关同步通信、状态变更时经 Kafka 异步通信——绝不伸进另一个服务的数据库。
- 用户服务——认证(OAuth2/JWT)、资料、地址。
- 店铺服务——商家店铺创建和设置。
- 商品服务——目录 CRUD、属性、媒体引用。
- 搜索服务——Elasticsearch 驱动的全文和分面搜索。
- 可服务性服务(Serviceability)——按购买资格(地区、合法性、可用性)过滤商品。
- 购物车服务——Redis 支撑购物车;库存服务负责持久化预留。
- 库存服务——权威库存计数和预留。
- 定价服务——动态定价、促销、优惠券。
- 订单服务——订单生命周期状态机,事务真相来源。
- 支付服务——包装外部网关(Stripe/PayPal),处理幂等和对账。
- 推荐服务——ML 驱动的个性化建议。
- 通知服务——email、SMS、push。
第 5 步 — 数据存储决策
存储选择由每个服务的访问模式和一致性需求驱动。没有单个数据库适合每个负载——多语言持久化正是重点。
| 数据类型 | 存储 | 理由 |
|---|---|---|
| 用户、订单、店铺 | SQL (RDBMS) | 金融交易和结构化关系数据需要 ACID |
| 商品目录 | NoSQL(文档) | 商品有异构属性集;文档存储避免大量 null 的 schema |
| 历史订单 | Cassandra | 横向可扩展、高写吞吐;适合追加重的归档 |
| 库存预留 | SQL + 截止时间 | 条件扣减与幂等回收;Redis 仅缓存状态 |
| 搜索索引 | Elasticsearch | 规模化的全文搜索、模糊匹配、分面过滤 |
| 媒体 | 对象存储 + CDN | PB 级 blob 服务到用户附近,挡在 DB 外 |
为什么商品用文档存储
笔记本有 CPU、RAM 和屏幕尺寸;T 恤有尺码和颜色;书有 ISBN 和页数。把这建模在单个关系表里产生要么一片可空列、要么一个笨拙的 entity-attribute-value schema。文档存储(MongoDB、DynamoDB)让每个商品恰好携带它需要的属性,且目录压倒性读多、无跨行事务——完美的 NoSQL 契合。
第 6 步 — 数据模型
订单和库存表住在 SQL,因为它们需要事务。一个简化 schema:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20), -- PENDING|PAID|SHIPPED|...
total_cents BIGINT NOT NULL,
idempotency_key VARCHAR(64) UNIQUE,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE inventory (
product_id BIGINT PRIMARY KEY,
available INT NOT NULL, -- 在库减预留
reserved INT NOT NULL,
version INT NOT NULL -- 乐观锁
);
idempotency_key UNIQUE 约束是使重试结账在数据库级别安全的东西:第二次插入只是唯一性检查失败,服务返回已创建的订单。库存上的 version 列支持乐观并发控制,下面覆盖。
第 7 步 — 结账管道
结账先持久化订单请求,再异步推进预留和支付。返回“请求已受理”不等于支付成功或订单已确认;只有库存预留与支付条件都满足,才发送确认并启动履约。
用户点击购买
→ 订单服务:同一事务写入 PENDING 订单与 OrderCreated outbox
→ 转发器发布到 Kafka;消费者按事件 ID 去重
→ 库存服务:按 order_id 幂等创建 SQL 预留
├── 无库存:订单取消;不发起支付
└── 预留成功:发起带固定操作幂等键的支付
├── 明确失败:取消订单并幂等释放预留
├── 结果未知:查询或重试同一支付操作
└── 支付成功:条件提交仍有效的预留
├── 预留已过期:退款或重新预留;不能直接履约
└── 提交成功:订单转 PAID,发送确认并启动履约
跨服务一致性:Saga 模式
单次结账触及订单、库存和支付服务——三个数据库,无分布式事务。标准答案是一个 Saga:一系列本地事务,各发布一个触发下一个的事件,失败时带补偿动作。若扣减库存后支付被拒,一个 PaymentFailed 事件驱动库存服务释放预留、订单服务把订单标 CANCELLED。这用原子性换可用性和最终一致——对一个必须在高峰保持运行的系统是正确选择。
两阶段提交给你原子性但在事务期间跨服务持锁,造成协调者瓶颈并在负载下使可用性暴跌。Saga 保持每个服务独立且高可用,代价是写显式补偿逻辑。
订单状态机
订单最好建模为一个显式状态机;这么做防止非法转换,如发货一个未付订单或退款两次。每个转换由一个事件触发且幂等,所以重放一个 Kafka 消息绝不错误推进状态。
PENDING ──paid──▶ PAID ──pack──▶ PACKED ──ship──▶ SHIPPED ──▶ DELIVERED
│ │
└─超时/失败─▶ CANCELLED └─退货──▶ REFUNDED (终态)
把状态条件放进更新语句,例如 UPDATE orders SET status='PACKED' WHERE id=:id AND status='PAID',并检查受影响行数。仅在更新前读取一次状态不能防止并发竞争。另行保存状态转换事件;当前状态与 updated_at 本身不是完整审计历史。
第 8 步 — 库存与超卖问题
交互练习:打开 并发预订与迟到支付案例,逐步比较旧值覆盖与原子扣减,并推演过期任务延迟、重复支付回调和退款去重。
购物车表示购买意向,不必为每次加入购物车都预留库存。本文选择结账时在库存服务的 SQL 事务内创建带截止时间的预留并扣减可售数。Redis 可缓存购物车和预留状态;数据库回收任务幂等恢复过期库存。闪购可提前预留,但必须明确有效期与限额。
防止并发超卖
当 10,000 个买家争抢 100 个单位,两个写绝不能都越过零成功。两个机制:
- 乐观锁——读带
version的行,然后UPDATE ... WHERE version = :v;若零行更新,别人赢了——重试。争抢通常低时很好。 - 条件原子更新——SQL 使用
UPDATE ... WHERE available >= :qty,将检查与扣减放进同一操作。RedisDECR本身允许负数;若选择 Redis 方案,需要 Lua 原子检查与扣减,还需另行解决持久化、故障切换与账本一致性。
-- 原子、并发下安全:仅当还有库存时成功
UPDATE inventory
SET available = available - 1, reserved = reserved + 1
WHERE product_id = 42 AND available > 0;
-- rows affected = 0 → 售罄,拒绝加入购物车
第 9 步 — 支付处理
把卡网络当作外部提供商(Stripe、PayPal、Adyen)——你几乎从不想自己做 PCI-DSS Level 1。支付服务是它周围一个薄、谨慎的包装:
- 幂等键——每次扣款尝试传一个唯一键,使超时后重试绝不双重扣款;提供商去重。
- webhook 驱动状态——绝不从同步响应假设成功;权威的
charge.succeeded/charge.failedwebhook 翻转订单状态。验证 webhook 签名。 - 对账——按库存预留期限及时处理未知支付结果,再以结算报告审计兜底。过期订单出现成功支付时复用退款或重新预留流程。
若扣款成功但 webhook 丢失会怎样?订单卡在 PENDING,客户的钱被取了。对账加一个超时后的"查询扣款状态"回退是安全网——这正是面试官探查的失败模式。
第 10 步 — 搜索与发现
商品和店铺更新经变更数据捕获(CDC)流入 Elasticsearch 集群:主数据库发出变更事件,一个 CDC 连接器(如 Debezium)把它们发布到 Kafka,Kafka 喂搜索索引器。这让索引保持最终一致而不把写延迟耦合到索引——并避免双写陷阱。
搜索服务处理模糊匹配、同义词和 type-ahead。可服务性服务随后在结果返回前按请求用户的地区和购买资格过滤它们。排序混合文本相关性与业务信号(热度、转化率、利润)。
双写(应用同时写 DB 和 Elasticsearch)在一个写失败的那一刻分歧——现在你的搜索显示不存在的商品或隐藏存在的。CDC 使数据库成为单一真相来源、索引成为它变更日志的下游、可重放投影。
第 11 步 — 为高峰流量扩展
闪购和季节性高峰(双 11、黑五)能把流量尖峰提数个数量级。逐层防御:
- CDN + 边缘缓存——静态资源甚至可缓存的商品页服务到用户附近。
- 多层缓存——目录和热门商品读前的 Redis;带合理 TTL 的 cache-aside。100:1 读比意味缓存命中承载大部分流量。
- 读副本——把读扇到副本;写保持在主库。
- 数据库分片——按
user_id分区订单、按product_id分区目录,使没有单节点成瓶颈。 - Kafka 作减震器——异步管道缓冲结账突发,使订单后端按自己节奏排干而非倾倒。
- 限流与负载卸除——保护平台免受滥用客户端和真实踩踏;为限量售卖把用户排进虚拟候车室。
- 自动扩缩——无状态服务按 CPU/延迟触发横向扩展。
商家处理模型
- 大企业(批拉)——Apache Airflow 调度批量库存/目录拉取;对高量定时更新更好。
- 中小企业(推模型)——商品或订单事件时立即推送变更以求新鲜。
深入缓存策略
100:1 读写比下,缓存是杠杆最高的单项优化。默认模式是 cache-aside:应用先查 Redis、未命中时回退到数据库、填充缓存。难的部分不是 happy path 而是失败模式:
- 失效——商品或价格更新时,发布一个事件驱逐或刷新缓存条目。结账时显示陈旧价格是真实的收入甚至法律风险,所以价格用短 TTL 缓存并急切失效。
- 缓存击穿——热 key 过期时,数千请求同时未命中并猛敲数据库。用随机 TTL 抖动、一个单飞互斥(只一个请求重建条目),或过期前后台 refresh-ahead 缓解。
- 热点 key——一个病毒商品能饱和一个 Redis 分片。跨分片复制热 key 或在 Redis 前加一个小进程内缓存吸收尖峰。
- 缓存什么——商品详情页、分类列表、搜索结果(短 TTL);绝不缓存权威库存计数或订单状态,它们必须总被强读。
深入分片策略
单个主库无法持有 5 亿商品或吸收峰值订单写,所以横向分区——而选好分片键是全部关键:
- 订单——按
user_id分片,使一个用户的完整历史住一个分片、常见的"我的订单"读保持单分片。按order_id分片会把一个用户的订单散到节点。 - 目录——按
product_id哈希分片以均匀分布、无热分区。 - 跨分片查询——"本月某区域所有订单"必须扇出到每个分片并合并,慢;把这类分析查询路由到 OLAP/数仓副本而非 OLTP 路径。
- 重分片——用一致性哈希或一个查找/目录服务,使加容量移动最少数据。朴素取模分片在分片数变化时重洗几乎每行。

第 12 步 — 可靠性与容错
99.99% 可用性下每个依赖最终都会失败;设计必须优雅降级而非崩溃。
- 复制 + 多 AZ——每个数据存储跨可用区带副本运行;主丢失时自动故障转移。
- 熔断器——推荐服务宕时,首页回退到一个非个性化的"热门商品" feed 而非报错。
- 幂等消费者——Kafka 保证至少一次投递,所以每个消费者按事件 ID 去重;重处理一个
OrderCreated不能扣两次库存。 - 死信队列——反复失败的事件被停放检查而非阻塞分区。
- 优雅降级——搜索宕?服务缓存的分类页。定价宕?服务最后已知价格。保持购买按钮活着。
可观测性
没有看进系统内部你无法在 99.99% 运作。倚靠三大支柱:
- 指标——每服务 RED(Rate、Errors、Duration)加业务指标(订单/分钟、支付成功率、购物车放弃)在 Prometheus/Grafana,每服务有仪表盘。
- 分布式追踪——跨网关 → 订单 → 库存 → 支付传播的 trace ID 把一个"结账慢"报告变成你能指出的确切 span。
- 结构化日志与告警——按
orderId/userId为键的集中日志便于 grep,并在 SLO 燃尽(如支付成功率 < 99%、p99 延迟 > 300 ms)而非原始资源用量上告警。
安全、欺诈与滥用
钱吸引攻击者,所以防御必须跨栈分层而非坐在单个门:
- 认证 / 授权——OAuth2 配短命 JWT 访问令牌加刷新令牌;商家和管理 API 带独立 scope,使泄露的客户令牌碰不到目录。
- 限流——网关处每用户和每 IP 令牌桶钝化撞库、抓取和意外客户端重试风暴。
- 欺诈评分——速度检查(短窗口内许多订单或许多卡)、设备指纹和一个 ML 风险分把可疑订单路由到履约前的持有/审查队列。
- 售卖的 bot 防御——限量闪购吸引 bot;结合 CAPTCHA、虚拟候车室和每账号购买限制。
- PCI 范围最小化——绝不存原始卡数据;经支付提供商 tokenize,使泄露不能漏卡号。
退货、退款与多区域
购后流程在面试里常被跳过但是真实范围。一次退货产生一个逆向物流任务;收到商品时,一次退款重入支付服务(又是幂等、又是 webhook 确认)并重新入库。退款是它们自己的小状态机,且必须像扣款一样对照提供商的结算报告对账。
对全球受众,一个多区域部署削减延迟并提供灾难恢复。目录和搜索只读复制到每个区域,而订单通常被钉到用户的主区域以保持事务路径简单,带异步跨区域复制做 DR。强全局一致昂贵且很少值得——大多数设计接受区域所有权加最终全局复制,只在区域宕机时故障转移到另一区域。
第 13 步 — ML 与推荐
用户行为信号——搜索查询、加入购物车、愿望单保存和完成购买——经 Kafka 捕获并喂进 Spark Streaming 做近实时分析。推荐服务用它们做:
- 用户分段(回归模型、KNN 聚类)。
- 个性化首页和"买了又买"模块。
- 动态定价和促销定向。
- 喂库存规划的需求预测。
每个表携带 created_at / updated_at 列,身兼两职:调试生产问题和为这些模型提供时间特征。
关键取舍
| 决策 | 选择 | 接受的取舍 |
|---|---|---|
| 订单一致性 | 强(SQL + ACID) | 比 NoSQL 更低写吞吐,由分片缓解 |
| 目录与搜索 | 最终(CDC) | 新商品可能需数秒出现在搜索 |
| 跨服务事务 | Saga | 必须写显式补偿逻辑;无全局回滚 |
| 库存持有 | SQL + 过期回收 | 持有后放弃的商品短暂不可用的小窗口 |
| 结账路径 | 经 Kafka 异步 | 订单在支付确认前已"下单";状态稍后更新 |
电商系统设计从根本上关于隔离一致性域:钱和订单用 SQL + ACID,灵活目录用文档存储,归档规模用 Cassandra,Kafka 把结账关键路径与所有下游效果解耦。持久化预留、条件扣减和幂等过期回收共同处理超卖与库存占用的取舍,Saga 模式是你不用分布式事务保持正确性的方式。
闪购期间怎么防止超卖? 库存服务在 SQL 中条件扣减可售数,并写入带截止时间的预留。同一预留只能提交或释放一次;Redis TTL 消失不等于 SQL 库存自动恢复。
为什么结账和支付间用 Kafka? 解耦请求受理与异步处理,并缓冲突发流量。订单和 outbox 同事务持久化;支付和库存提交都成功后才确认订单与履约。
不用 2PC 怎么保持三个服务一致? 用 Saga 保存各服务的本地事务与补偿进度,结合 outbox、幂等操作和条件状态转换处理失败与重放。
为什么 CDC 进 Elasticsearch 而非双写? CDC 把搜索索引作为可重建投影,避免数据库成功而直接写索引失败造成长期分歧。
怎么避免给客户双重扣款? 为每个支付操作保存固定幂等键和支付对象 ID;回调去重并校验状态,结果未知时查询或重试同一操作,再以对账兜底。
面试追问:结账失败时如何保持一致
先说清不变式:可售库存不为负;一个订单的预留只提交或释放一次;支付结果未知时不重复创建扣款;未同时满足库存和支付条件的订单不能履约。
- 数据库写成功,Kafka 发送失败:订单与 outbox 同事务提交,后台重发;消费侧去重。不能用“先写数据库再发消息”假装两者原子。
- 回调和预留过期同时发生:库存服务对同一预留做条件状态转换。若释放先成功,支付成功也不能直接发货,应退款或重新获得库存。
- 多件商品只预留成功一部分:同库可用一个事务;跨库存分片用 Saga 释放已成功的部分,并保存每一步的操作 ID。
- 订单已支付但进程崩溃:从持久化状态和 outbox 恢复通知、履约;每个下游处理器都能安全重试。
用“最后一件库存、两个并发买家”和“退款响应丢失后重试”检验方案。对比 酒店预订的多晚库存与取消示例,再复习 DDIA 第 7 章的事务和 第 8 章的部分失败。