系统设计:设计一个电商系统(淘宝/Amazon 级别)

电商系统是系统设计面试中最经典的题目之一,覆盖商品、订单、库存、搜索、推荐、支付等几乎所有核心子系统。本文从全局视角拆解一个淘宝/Amazon 量级的电商平台设计。

1. 需求分析

功能需求

  • 商品浏览、搜索、详情页
  • 购物车管理
  • 下单、支付、订单管理
  • 库存管理(防超卖)
  • 商家后台(上架、库存管理)
  • 评价与评论系统

非功能需求

指标 目标
用户规模 1 亿日活(DAU)
商品数量 10 亿+ SKU
峰值 QPS 100 万/秒(大促期间)
下单延迟 P99 < 500ms
库存一致性 强一致,不允许超卖
可用性 99.99%

关键挑战:

  • 大促流量洪峰:双十一峰值 QPS 是日常的 10-100 倍
  • 库存超卖:高并发下的库存扣减一致性
  • 数据规模:10 亿 SKU 的商品检索与展示

2. 整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
用户层
│ Web / App

[CDN] ← 静态资源(图片、CSS、JS)

[API Gateway / 负载均衡]
│ 限流、鉴权、路由
├─────────────────────────────────────────────┐
▼ ▼
[商品服务] [搜索服务] [购物车] [订单服务] [库存服务]
│ │ │ │ │
[商品DB] [Elasticsearch] [Redis] [订单DB] [库存DB]

[支付服务]

[第三方支付网关]

3. 核心子系统设计

3.1 商品系统

商品数据量大(10 亿 SKU),读多写少,适合读写分离 + 多级缓存。

数据模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 商品 SPU(标准产品单元,如"iPhone 16")
CREATE TABLE product_spu (
spu_id BIGINT PRIMARY KEY,
title VARCHAR(200),
category_id BIGINT,
brand_id BIGINT,
description TEXT,
status TINYINT, -- 0:下架 1:上架
created_at DATETIME,
updated_at DATETIME
);

-- 商品 SKU(库存单元,如"iPhone 16 黑色 256GB")
CREATE TABLE product_sku (
sku_id BIGINT PRIMARY KEY,
spu_id BIGINT,
price DECIMAL(12,2),
attrs JSON, -- {"颜色":"黑色","存储":"256GB"}
image_url VARCHAR(500),
status TINYINT
);

SPU vs SKU 的区别:

  • SPU(Standard Product Unit):一类商品的抽象,如”iPhone 16”
  • SKU(Stock Keeping Unit):具体可售卖的单品,如”iPhone 16 黑色 256GB”
  • 一个 SPU 对应多个 SKU,库存和价格绑定在 SKU 层面

多级缓存架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
请求

▼ 1. 浏览器缓存(Cache-Control: max-age=300)

▼ 2. CDN 边缘节点缓存(命中率 ~80%)

▼ 3. 本地内存缓存(Guava Cache / Caffeine,单机)
│ 热门商品,容量小但延迟极低(<1ms)

▼ 4. 分布式缓存(Redis Cluster)
│ Key: product:{sku_id},TTL 10min
│ 命中率 ~95%

▼ 5. 数据库(MySQL + 只读从库)
主库写,多从库读,读写分离

缓存更新策略:

  • 商品更新时:先更新 DB,再删除 Redis 缓存(Cache-Aside 模式)
  • 不用”更新缓存”而用”删除缓存”:避免并发写导致旧值覆盖新值

商品详情页静态化

1
2
3
4
5
商品详情页(大流量入口):
├── 静态部分(标题、描述、图片)→ 提前渲染成 HTML → 存 CDN
│ 更新商品时触发 CDN 缓存刷新
└── 动态部分(价格、库存、优惠)→ 前端异步 Ajax 拉取
价格和库存变化频繁,不能静态化

3.2 搜索系统

商品搜索需要支持全文检索、多维度过滤和个性化排序。

技术选型:Elasticsearch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
为什么选 ES 而非直接查 MySQL?
├── MySQL LIKE '%keyword%' → 全表扫描,10 亿 SKU 不可接受
├── ES 倒排索引 → 毫秒级全文检索
└── ES 支持多字段聚合过滤(品牌/价格/类目/属性)

索引结构:
{
"sku_id": 123456,
"title": "iPhone 16 Pro 黑色 256GB",
"brand": "Apple",
"category": "手机",
"price": 9999.0,
"attrs": {"颜色": "黑色", "存储": "256GB"},
"sales_volume": 10000, // 销量(用于默认排序)
"score": 4.8, // 评分
"status": 1
}

搜索排序策略

1
2
3
4
5
6
7
8
9
10
综合排序分 = α × 相关性得分(BM25)
+ β × 销量得分(归一化)
+ γ × 评分得分
+ δ × 新鲜度(上架时间衰减)
+ ε × 个性化得分(用户历史行为)

不同场景权重不同:
├── 用户主动搜索:α(相关性)权重最高
├── 分类浏览:β(销量)权重最高
└── 个性化推荐页:ε(个性化)权重最高

数据同步:商品 DB → ES

1
2
3
4
5
6
7
8
9
10
11
12
MySQL (主库)
│ binlog

Canal (监听 binlog)
│ 解析变更事件

Kafka(缓冲)

ES Indexer(消费)
│ 增量更新 ES

Elasticsearch

3.3 购物车系统

购物车的核心特点:读多写多,数据量中等,允许最终一致。

存储方案:Redis Hash

1
2
3
4
5
6
7
8
9
10
11
12
Key:   cart:{user_id}
Field: {sku_id}
Value: {quantity, price_snapshot, selected}

HSET cart:1001 sku:5001 '{"qty":2,"price":9999.0,"selected":true}'
HSET cart:1001 sku:5002 '{"qty":1,"price":299.0,"selected":false}'
TTL: 30 天

为什么用 Redis 而不是 MySQL?
├── 购物车是高频读写(每次修改数量都写)
├── 数据量适中,Redis 内存可承受
└── 允许最终一致(掉电丢失可重新加入,不影响业务)

价格快照: 购物车存储加购时的价格快照,结算时重新获取最新价格并展示差价提示,防止用户误解。

未登录购物车: 存浏览器 LocalStorage,登录后合并到服务端购物车(按用户 ID 合并,数量取最大值)。

3.4 库存系统(核心难题)

库存防超卖是电商系统最核心的技术挑战,需要在高并发下保证强一致。

超卖场景复现

1
2
3
4
5
6
库存 = 1,两个请求并发到来:

T1: 查库存 → 1 > 0,可下单
T2: 查库存 → 1 > 0,可下单 ← 读到同样的值!
T1: 扣库存 → 库存变为 0
T2: 扣库存 → 库存变为 -1 ← 超卖!

方案一:数据库悲观锁(SELECT FOR UPDATE)

1
2
3
4
5
BEGIN;
SELECT stock FROM inventory WHERE sku_id = 5001 FOR UPDATE;
-- 其他事务被阻塞,等待锁释放
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 5001 AND stock > 0;
COMMIT;

缺点: 锁粒度大,高并发下数据库连接数耗尽,性能差。适合低并发场景。

方案二:数据库乐观锁(CAS)

1
2
3
4
5
6
-- 不加行锁,用版本号 CAS
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE sku_id = 5001 AND stock > 0 AND version = #{current_version};

-- 影响行数为 0 说明被抢先修改,重试或失败

缺点: 高并发下大量 CAS 失败,重试风暴。

方案三:Redis 原子扣减(推荐)

1
2
3
4
5
6
7
-- Lua 脚本保证原子性(Redis 单线程执行)
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) <= 0 then
return -1 -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1 -- 扣减成功
1
2
3
4
5
6
7
8
9
10
11
12
库存预热:
大促前将库存 load 到 Redis:
SET inventory:sku:5001 1000

下单流程:
1. Redis 原子扣减库存(Lua 脚本)
2. 扣减成功 → 异步写订单到 DB + 发 MQ 消息
3. 扣减失败 → 返回"库存不足"

库存同步:
Redis → MySQL 定期同步(最终一致)
支付成功后:MySQL 真正扣减持久化库存

大促库存分桶:

1
2
3
4
5
6
7
8
9
单个 Redis Key 并发 DECR 仍有热点问题
解决:将 1000 库存拆成 10 个桶,每桶 100

inventory:sku:5001:0 → 100
inventory:sku:5001:1 → 100
...
inventory:sku:5001:9 → 100

下单时:按 user_id % 10 选桶,分散热点

3.5 订单系统

订单状态机

1
2
3
4
5
待付款 → 已付款 → 待发货 → 已发货 → 已完成
│ │
└─→ 已取消 已评价

└─→(支付超时自动取消,30分钟)

数据库分库分表

订单数据量大(日增千万级),必须分库分表。

1
2
3
4
5
6
7
8
9
10
11
分片策略:按 user_id 取模

user_id % 16 → 16 个库
每库内按 order_id 取模 → 16 张表

总计 256 张表(满足未来 10 年增长)

-- 路由示例
db_index = user_id % 16 → db_05
table_index = order_id % 16 → order_05
全路径:db_05.order_05

order_id 生成(雪花算法 Snowflake):

1
2
3
4
5
6
7
64 bit:
[1位符号] [41位时间戳ms] [10位机器ID] [12位序列号]

特点:
├── 全局唯一,趋势递增(B-Tree 友好)
├── 不依赖数据库自增
└── 单机每毫秒最多 4096 个 ID

为什么不按 order_id 分片?

  • 查询”我的订单列表”需按 user_id 查
  • 若按 order_id 分片,同一用户的订单散落多表,需要 scatter-gather,性能差

订单创建流程(防重与幂等)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
客户端
│ 1. 获取下单 token(防重令牌)

[订单服务]
│ 2. Redis: SET order_token:{token} 1 NX EX 300
│ NX 保证同一 token 只能成功一次

│ 3. 预占库存(Redis 扣减)

│ 4. 创建订单记录(DB,包含 token)

│ 5. 发送 MQ 消息(支付超时任务)

└─→ 返回 order_id 给客户端

4. 高并发大促设计

4.1 限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
多层限流(防止流量击穿):

[入口层 - Nginx]
└── 全局 QPS 限制,漏桶算法
超限返回 503

[API Gateway]
└── 接口级限流(令牌桶算法)
/api/order/create → 10 万 QPS

[服务层]
└── 单用户频率限制
单 user_id 1 秒最多下 5 单
Redis: INCR order_rate:{user_id} EX 1

[数据库层]
└── 连接池限流,拒绝超量请求

4.2 熔断降级

1
2
3
4
5
6
7
8
9
大促期间,非核心功能主动降级:

降级开关(配置中心,实时生效):
├── 关闭推荐系统 → 首页展示热销榜
├── 关闭评价系统 → 商品详情不展示评论
├── 关闭历史记录 → 不记录浏览历史
└── 购物车只读 → 不允许新增,只允许结算

保留核心链路:搜索 → 商品详情 → 下单 → 支付

4.3 秒杀系统

秒杀是电商最极端的并发场景(1 秒内几十万人抢 100 件商品)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
核心思路:在最前层过滤掉 99% 的请求

[用户请求]

▼ 1. CDN 静态页面(秒杀页无需访问服务器)

▼ 2. 请求随机丢弃(Nginx:只放行 1% 请求)
│ 大量请求在入口拦截

▼ 3. 用户资格验证(Redis 检查:是否已购买/黑名单)

▼ 4. 内存队列(Queue)
│ 只放入 N 个请求(N = 库存数)
│ 多余请求立即返回"售罄"

▼ 5. 异步处理(Worker 消费队列)
│ 扣库存 → 创建订单 → 发送支付链接

▼ 6. 用户轮询结果

5. 存储选型总结

数据 选型 理由
商品基础信息 MySQL(读写分离) 结构化,支持复杂查询
商品全文搜索 Elasticsearch 倒排索引,毫秒级检索
商品详情缓存 Redis + CDN 读多写少,降低 DB 压力
购物车 Redis Hash 高频读写,允许最终一致
库存计数 Redis(Lua 原子扣减) 高并发防超卖
订单数据 MySQL(分库分表) 强一致,支持事务
用户行为日志 Kafka + Hive 流式摄入,离线分析
会话/Token Redis 低延迟,支持 TTL
商品图片 对象存储(OSS/S3)+ CDN 大文件,高并发读

6. 架构演进路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
阶段一(0→1,单体):
├── 单体应用 + 单 MySQL + 单 Redis
└── 快速上线,验证业务

阶段二(1→10,拆分):
├── 按业务拆微服务(商品、订单、库存分开)
├── 数据库读写分离
└── 引入 MQ 解耦

阶段三(10→100,扩展):
├── 订单分库分表
├── 引入 Elasticsearch 做搜索
└── 多级缓存体系建立

阶段四(100→∞,大促):
├── 秒杀独立集群隔离
├── 全链路压测
└── 降级开关体系

7. 面试要点总结

维度 关键设计
防超卖 Redis Lua 原子扣减 + 库存分桶
订单分片 按 user_id 分库,order_id 用 Snowflake
缓存 多级缓存(本地 → Redis → DB),Cache-Aside 更新
搜索 ES 倒排索引,binlog → Canal → Kafka → ES 同步
大促 多层限流 + 非核心降级 + 秒杀前置过滤
幂等 防重令牌(Redis NX),MQ 消费幂等(唯一消息 ID)
可用性 核心链路与非核心链路隔离,熔断降级

参考资料