摘要:前面三篇分别讲了系统的运转(请求生命周期)、排障(深夜告警)和预测(容量规划),这篇讲更前置的能力:拿到一个新需求,怎么从一张白纸推演出一套合理的架构。以"给一家中型电商从零设计订单系统"为主线,完整走一遍架构设计的思考路径:先问业务(需求分析)、再问规模(容量估算)、再定边界(服务拆分)、再选存储(数据建模)、再设计核心流程(下单链路)、最后设计非功能需求(幂等/超时/降级)。每一步给"问什么问题、有哪些选项、怎么选、选错了会怎样"。这不是一篇"微服务架构最佳实践",而是一篇决策过程的完整记录——架构的价值不在画出来的图,而在图背后的每一次取舍。建议收藏。
目录
- 架构设计的误区:从"画图"开始是错的
- 第一步:问业务——需求里藏着 80% 的架构决策
- 第二步:问规模——数字决定形态
- 第三步:定边界——单体还是微服务,一张决策表
- 第四步:选存储——订单数据的三种形态与三种库
- 第五步:设计核心流程——下单链路的每一步取舍
- 第六步:非功能设计——幂等、超时、降级三板斧
- 架构评审:怎么让别人接受你的设计
- 总结:架构是长出来的,不是设计出来的
1. 架构设计的误区:从"画图"开始是错的
先说一个普遍的误区:拿到需求,打开画图工具,开始画微服务框图——用户服务、订单服务、支付服务,中间加个消息队列,再配个网关。图很漂亮,但这个顺序是反的。
架构图是架构设计的输出,不是输入。在画出第一张框图之前,你至少要回答三个问题:
- 业务到底要什么——不是"做一个订单系统",而是"订单创建的高峰在哪、退款率和支付成功率是多少、超时未支付的订单怎么处理";
- 规模到底多大——日订单 1 万和日订单 500 万,是两个完全不同的系统;
- 团队多大多强——5 人团队上 12 个微服务,是自杀式架构。
架构设计的正确顺序:先问业务,再问规模,再定边界,再选存储,再画图。画图永远是最后一步。下面以一个具体需求为主线走完这个流程。
需求原文(甲方的原话通常就这么短):
"我们要做一个线上商城,能下单、能支付、能退款。"
就这一句话。接下来的每一节,都是从这句话里"榨"出架构决策的过程。
2. 第一步:问业务——需求里藏着 80% 的架构决策
"能下单、能支付、能退款"九个字,藏着至少十个必须问清的业务问题。每一个问题的答案都直接改变架构:
必须问的问题 | 为什么影响架构 | 假设的答案 |
订单高峰在什么时候? | 决定是否要削峰、限流 | 晚 8~10 点双峰,峰值系数 6% |
有没有秒杀/抢购场景? | 秒杀和普通下单是两套系统 | 一期没有,二期可能有 ⚠️ |
超时未支付的订单怎么办? | 需要"订单超时关闭"机制,决定是否引入延迟消息 | 30 分钟未支付自动取消 |
退款流程有多复杂? | 仅退款 vs 退货退款,状态机复杂度差 3 倍 | 退货退款,需审核 |
支付渠道有几家? | 多渠道适配层的设计 | 微信+支付宝两家 |
库存是否要防超卖? | 决定库存扣减方案(下节展开) | 必须防超卖(硬要求) |
订单要保留多久? | 归档策略、冷热分层 | 3 年合规要求 |
发票/对账要不要? | 对账是独立子系统 | 要,日终对账 |
移动端还是 Web? | API 设计、认证方式 | 都要,一套 API |
一期什么时候上线? | 时间决定范围,范围决定架构 | 3 个月 ⚠️ |
最后两个问题最重要,也最常被忽略:
"有没有秒杀"这个问题的答案,价值等于一周的架构工作量。秒杀场景的架构(内存预扣库存、令牌桶排队、异步落单)和普通下单完全是两套系统。一期没有就按普通下单设计,但架构要预留秒杀的扩展点(库存扣减层独立出来),否则二期加秒杀就是重写。
"3 个月上线"这个约束比所有技术问题加起来都重。3 个月、5 人团队,意味着:单体或轻度拆分(3~4 个服务封顶)、不引入复杂中间件、先把主链路跑通。架构是被时间和团队规模约束出来的,不是被技术理想约束出来的。
3. 第二步:问规模——数字决定形态
业务问清后,用容量模型(上一篇的方法)算出系统的规模画像:
规模画像(从业务数字推导):
预计日订单 20 万单
→ 峰值下单 QPS = 200,000 × 6% / 7200s(双峰) ≈ 170 QPS
→ 大促 5 倍 ≈ 850 QPS
→ 商品浏览峰值 ≈ 下单 × 30 = 5000 QPS(读多写少!)
关键数字:
· 写 QPS(下单):峰值 170,大促 850 ← 写压力很小!
· 读 QPS(浏览):峰值 5000,大促 25000 ← 读压力是写的 30 倍
· 订单表年增量:20 万/天 × 365 ≈ 7300 万/年 ← 单表 2 年内到亿级
· 一期团队:5 人 ← 人力是最稀缺资源
这四个数字直接决定后面所有技术选型:
- 读写比 30:1→ 读路径优化(缓存、读写分离)优先级远高于写路径;写架构可以简单(单库),读架构必须设计(缓存层)。
- 写峰值 850 QPS→ 一个 PG 主库绰绰有余(单库写 3000+ QPS 无压力),分库分表一期完全不需要。
- 订单表年增 7300 万→ 2 年内单表过亿,要在表设计时就预留分表方案(按 user_id 分片),但一期不实施。
- 5 人团队→ 3~4 个服务封顶,绝对不上"每个领域一个微服务"的教科书架构。
规模画像的本质:把"我们要做一个大系统"翻译成 8 个以内可以验证的数字。数字不到某个量级,就不要用那个量级的架构。
4. 第三步:定边界——单体还是微服务,一张决策表
现在可以回答"单体还是微服务"了。这不是信仰问题,是决策问题:
维度 | 单体 | 轻度拆分(3~4 服务) | 完整微服务(10+) |
适合规模 | QPS < 500,团队 < 5 | QPS < 5000,团队 5~15 | QPS 万级,团队 15+ |
部署复杂度 | 极低 | 低 | 高(需要 K8s 全家桶) |
故障隔离 | 无(一挂全挂) | 部分 | 好 |
独立扩容 | 无 | 读服务可独立扩 | 好 |
开发效率 | 最高 | 高 | 低(联调/发版协调) |
适合本例? | 边缘 | ✅ 正确答案 | 过度设计 |
本例的答案:轻度拆分,3 个服务。拆分依据不是"领域驱动设计的标准拆法",而是两个实际理由:
3 服务的拆法(按"变化速率"和"资源特征"拆,不按教科书领域拆):
┌────────────────────────────────────────────────┐
│ ① 商城核心服务(单体): │
│ 商品浏览 + 购物车 + 订单创建 │
│ 理由:三者共享数据模型、一起发布、写压力小 │
├────────────────────────────────────────────────┤
│ ② 支付服务(独立): │
│ 支付渠道适配 + 回调处理 + 对账 │
│ 理由:安全敏感(独立部署/审计)、渠道频繁变化、 │
│ 回调是异步流量(可与主站独立扩容) │
├────────────────────────────────────────────────┤
│ ③ 消费者服务(异步): │
│ Kafka 消费者集合:短信/积分/风控/数据分析 │
│ 理由:纯异步流量,可独立扩容、独立故障, │
│ 消费堆积不影响主链路 │
└────────────────────────────────────────────────┘
拆分的两条原则:
- 按"变化速率"拆:变化快的(支付渠道)和变化慢的(订单核心)分开,让它们独立发布、互不阻塞。微服务的真正收益是发布独立性,不是技术先进性。
- 按"资源特征"拆:同步流量(用户在等)和异步流量(系统后台处理)分开——消费者服务堆积不影响下单主链路,这是故障隔离的实际价值。
什么时候该进一步拆?让监控告诉你:当某个模块的发布频率明显拖累其他模块(想发订单必须连商品一起发),或者某个模块的资源需求和整体错位(消费者想扩容但商品服务被迫跟着扩),再拆不迟。架构是长出来的:先用轻架构扛住,让真实的痛点告诉你下一刀切哪里。
5. 第四步:选存储——订单数据的三种形态与三种库
订单数据有三种访问形态,对应三种存储:
数据形态 | 访问特征 | 存储选型 | 为什么 |
热数据(3 个月) | 按 user_id/order_id 精确查、状态流转更新 | PG 单库(预留分表) | 事务强一致,写 QPS 低 |
用户订单列表 | 按 user_id + 时间倒序分页 | PG 索引 + 缓存 | 索引足够,不需要 ES |
商家/运营查询 | 按时间/状态/商品多维度组合查 | ES(异步同步) | 多维组合查询是 PG 索引的弱项 |
一个刻意的设计决策:订单列表不用 ES,商品搜索才用。用户查自己的订单只有一种模式(我的订单、按时间倒序),一个复合索引(user_id, created_at desc)完全覆盖;把订单列表塞进 ES 是典型的"为技术找场景"。ES 只服务"商家多维度查订单"这一个真正的弱项场景,且从 Kafka 异步同步(允许分钟级延迟)。
存储设计的两条原则:
- 一个数据一种主存储:PG 是订单的 source of truth,ES 是派生的查询视图(可随时重建)。派生视图坏了可以重建,主存储绝不能坏——这个"主从关系"必须在架构里显式声明,否则会出现"两边都当主"的数据一致性灾难。
- 分表方案现在设计、以后实施:订单表按user_id哈希分 16 张表(orders_00~orders_15),一期逻辑分表(还在一个库里,但表名带分片),2 年后数据量逼线时物理分库,应用层无感迁移。分表是"现在设计、以后实施",分库是"数据说了算",都不是"现在就上"。
5.1 订单表的建模:状态机即表结构
存储选型之后是具体的表建模。订单表的建模核心是把状态机直接翻译进表结构——状态字段、流转时间戳、以及"每一步谁做的":
CREATE TABLE orders_00 (
id BIGSERIAL,
order_no TEXT UNIQUE NOT NULL, -- 业务订单号(对外展示)
user_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_pay',
-- pending_pay → paid → shipped → done
-- ↘ cancelled
-- paid → refunding → refunded
total_amount BIGINT NOT NULL, -- 金额用整数分(BIGINT),绝不用浮点
pay_amount BIGINT NOT NULL,
pay_channel TEXT, -- wechat / alipay
pay_trade_no TEXT, -- 渠道交易号(幂等+对账)
created_at TIMESTAMPTZ DEFAULT now(),
paid_at TIMESTAMPTZ, -- 每个状态流转都留时间戳
shipped_at TIMESTAMPTZ,
done_at TIMESTAMPTZ,
close_reason TEXT, -- cancelled/timeout 的原因
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 索引:按"实际查询模式"建,不按"感觉可能查"建
CREATE INDEX ON orders_00 (user_id, created_at DESC); -- 用户订单列表(主力查询)
CREATE INDEX ON orders_00 (status, created_at) -- 超时扫描(WHERE status='pending_pay')
WHERE status = 'pending_pay'; -- 部分索引:只为超时扫描服务
三个建模细节,每个都对应一类真实事故:
- 金额用整数分(BIGINT),绝不用浮点:浮点数的 0.1+0.2 ≠ 0.3,订单金额出现 1 分钱误差就是对账灾难。金额的一切运算都在整数域。
- 每个状态流转留时间戳:paid_at/shipped_at/done_at不只是记录,是对账与争议仲裁的唯一依据——"用户说付了但系统没发货",靠的就是这些时间戳和渠道回调日志。
- 部分索引为特定查询服务:超时扫描只关心pending_pay状态的订单,部分索引让这个扫描从全表索引扫描变成只有几百行的索引——索引是为查询模式服务的,不是为表服务的。感觉"以后可能要查"的索引不要建:每个索引都是写入的税。
数据建模的本质:把业务规则(状态机、金额精度、幂等键)直接翻译成表结构和约束——凡是数据库层面能约束的(NOT NULL、UNIQUE、状态条件更新),都不要留给应用层自觉。数据库是最后防线,应用层代码会被人改错,约束不会。
6. 第五步:设计核心流程——下单链路的每一步取舍
核心链路是架构的心脏。下单链路有四个必须设计的环节,每个环节都有取舍:
6.1 环节一:库存扣减——防超卖的三种方案
"防超卖"是本需求的硬约束。三种主流方案:
方案 | 原理 | 优点 | 缺点 | 适合 |
数据库行锁(乐观扣减) | UPDATE stock SET n=n-1 WHERE sku=? AND n>=1 | 强一致、实现极简 | 热点行锁竞争,QPS 上限 ~500 | 本例正确答案 |
Redis 预扣 + 异步落库 | Redis 原子扣减,异步写 DB | QPS 极高 | 一致性复杂(对账补偿) | 秒杀场景 |
队列串行化 | 扣减请求全部排队串行处理 | 绝对不超卖 | 吞吐受限于单消费者 | 超高价值商品 |
本例选方案一,理由来自规模画像:写峰值 850 QPS(大促),单行锁扣减在 PG 上实测可扛 500~800 QPS,够用。方案二的 Redis 预扣是秒杀的答案,一期用它是杀鸡用牛刀还多养了一致性难题。但架构上预留升级路径:库存扣减独立成模块(接口隔离),二期秒杀只替换实现不改调用方。
-- 乐观扣减:一条 SQL 天然防超卖(affected_rows = 0 即售罄)
UPDATE inventory SET stock = stock - 1
WHERE sku_id = $1 AND stock >= 1;
6.2 环节二:订单创建——同步与异步的切分线
下单接口里,哪些步骤同步做、哪些异步做?切分线只有一条:用户当下需要知道结果的,同步;其余全部异步。
下单接口(同步,目标 < 300ms) 异步(Kafka 事件驱动)
┌─────────────────────┐ ┌──────────────────────┐
│ 1. 参数校验 │ │ 发送短信通知 │
│ 2. 库存乐观扣减 ✅ │ ┌─────┐ │ 积分发放 │
│ 3. 创建订单(事务)✅ │──→│Kafka│──→│ 风控扫描 │
│ 4. 生成支付单 ✅ │ └─────┘ │ 数据分析 │
│ 5. 投递事件 (1ms) │ │ 商家 ES 同步 │
└─────────────────────┘ │ 超时关闭定时检查 │
↑ 用户等的就是"下单成功" └──────────────────────┘
↑ 失败可重试,不影响用户
切分线的两个推论:
- 库存扣减必须同步(用户要知道"有没有抢到"),但它要轻(一条 UPDATE);
- 积分、短信必须异步(用户不需要下单瞬间收到积分),且消费者幂等(重复消费不重复发积分,幂等键 = 订单号 + 事件类型)。
6.3 环节三:订单超时关闭——延迟消息的两种实现
"30 分钟未支付自动取消"看似简单,工程上两种方案:
方案 | 原理 | 优点 | 缺点 |
延迟消息 | RabbitMQ 死信/Kafka 定时轮询,30 分钟后投递 | 实时、精确 | 引入新中间件 |
定时扫描 | 每 5 分钟扫"超时未支付"的订单批量关闭 | 零新组件、天然幂等 | 最多延迟 5 分钟关闭 |
本例选定时扫描:5 分钟的关闭延迟在业务上完全可接受(库存占用多 5 分钟而已),换来零新中间件。技术选型的原则:中间件是负债不是资产——每引入一个新组件,你都要养它一辈子(部署、监控、升级、排障),除非收益明确大于这份负债。
-- 定时关闭:天然幂等(状态条件保证重复执行安全)
UPDATE orders SET status = 'closed', close_reason = 'timeout'
WHERE status = 'pending_pay'
AND created_at < now() - interval '30 minutes';
6.4 环节四:支付回调——钱相关的一切都要幂等
支付回调是整个系统唯一不能出错的地方(多发货或少发货都是资损)。三条铁律:
# payment/callback.py —— 支付回调处理(骨架)
async def handle_pay_callback(order_id: str, amount: int, channel: str,
channel_trade_no: str, sign: str):
# 铁律一:先验签,后处理(伪造回调 = 直接资损)
if not verify_sign(sign, order_id, amount, channel_trade_no):
raise HTTPException(400, "invalid signature")
# 铁律二:幂等优先——按渠道交易号幂等,重复回调直接返回成功
# (支付渠道会重发回调,不幂等 = 重复发货)
inserted = await db.execute(
"""INSERT INTO pay_callbacks(channel_trade_no, ...) VALUES (...)
ON CONFLICT (channel_trade_no) DO NOTHING""")
if inserted == 0:
return "SUCCESS" # 已处理过,直接确认(渠道要求返回成功防重发)
# 铁律三:金额校验——回调金额 ≠ 订单金额,一律挂起人工介入
order = await db.fetchrow("SELECT pay_amount FROM orders WHERE id=$1", order_id)
if amount != order["pay_amount"]:
await alert_ops(f"金额不一致: {order_id} {amount} != {order['pay_amount']}")
return "SUCCESS" # 先确认防重发,人工介入处理
# 状态机流转:只允许 pending_pay → paid,其他状态流转拒绝(防乱序回调)
updated = await db.execute(
"""UPDATE orders SET status='paid', paid_at=now()
WHERE id=$1 AND status='pending_pay'""")
if updated == 0:
await alert_ops(f"状态流转异常: {order_id}") # 可能是乱序/重复回调
await producer.send("order-events", {"type": "paid", "order_id": order_id})
return "SUCCESS"
三条铁律之外还有一个隐性设计:状态机显式建模。订单状态(pending_pay → paid → shipped → done,加 cancelled/refunding 分支)是严格的有向图,所有状态流转都走WHERE status = 期望前态的条件更新——条件更新让乱序回调、重复回调天然安全,比"先查后改"的写法安全一个量级。
7. 第六步:非功能设计——幂等、超时、降级三板斧
核心流程之外,三个非功能设计必须一期就做,它们不是"优化项",是"正确性的一部分":
幂等:所有写接口都要设计幂等键。下单接口幂等键 = 客户端生成的请求 ID(前端防重复提交),支付回调幂等键 = 渠道交易号(渠道防重发)。幂等键的选择标准:同一个业务动作,不管发生几次,幂等键必须相同。
超时:所有跨服务调用必须显式设置超时,且上游超时 > 下游超时之和(否则上游先超时了,下游还在白干)。下单链路:网关 5s > 下单接口 3s > 库存 1s + DB 0.5s。没有超时的调用等于"愿意为它等到天荒地老"。
降级:每个非核心依赖都要有降级方案,且降级方案要演练过。下单链路的降级矩阵:
依赖 | 挂了会怎样 | 降级方案 |
库存服务 | 不能下单 ❌ 核心依赖,不降级 | 扩容 + 熔断排队 |
推荐服务 | 详情页缺一块 | 降级:返回默认位图,页面正常 |
积分服务 | 用户没积分 | 异步重试 + 死信队列,无感 |
ES 查询 | 商家查不了订单 | 降级:回退 PG 简单查询 |
降级矩阵的价值在"事前共识":大促当天,"推荐服务要不要降级"不应该是现场争论的问题,而是预案里写好的答案。
8. 架构评审:怎么让别人接受你的设计
架构文档写完只是开始,评审通过才算数。四条实战建议:
- 先讲决策,再讲图。评审会上前 10 分钟讲三个决策(为什么轻度拆分、为什么不用 ES 存订单列表、为什么定时扫描代替延迟消息)——决策被接受了,图怎么画都没人反对;决策没被接受,图画得再美也会被推翻。
- 每个决策配"为什么不选另一个"。评审的质疑永远是"为什么不用 X"——"为什么不用 Redis 扣库存""为什么不用消息队列做超时关闭"。方案的 Strength 没有说服力,替代方案的 Weakness 才有说服力。每个决策备好"另一个选项 + 它在本场景的具体问题"。
- 给架构留"被推翻的余地"。明确说出"什么情况下这个设计要改"——"如果日订单过百万,订单表物理分库""如果上了秒杀,库存扣减换 Redis 方案"。主动暴露边界比被别人戳穿体面得多,也说明你真的想过。
- 写"两页纸摘要"。完整文档 30 页没人看,评审前发两页纸:规模画像(8 个数字)+ 3 个核心决策 + 遗留风险。架构沟通的效率决定架构落地的效率。
9. 总结:架构是长出来的,不是设计出来的
架构设计的完整流程回顾:
一句话需求
→ 问业务(10 个问题,藏着 80% 决策)
→ 问规模(8 个数字,数字决定形态)
→ 定边界(3 个服务,按变化速率和资源特征拆)
→ 选存储(三种形态三种库,一种数据一种主存储)
→ 核心流程(四个环节,每环节有取舍)
→ 非功能(幂等/超时/降级三板斧)
→ 画图(最后一步)
六句话收束全文:
- 架构图是输出不是输入——画图之前先问业务、算规模,顺序反了就是空中楼阁。
- 数字决定形态:写 850 QPS 的系统不需要分库分表,读写比 30:1 的系统先优化读路径。
- 微服务的真正收益是发布独立性——按变化速率拆,不按教科书领域拆;5 人团队 3 个服务封顶。
- 一个数据一种主存储——PG 是事实源,ES 是派生视图;派生视图可重建,主存储不能坏。
- 中间件是负债不是资产——定时扫描换掉延迟消息,省下的不是钱,是一辈子的运维责任。
- 架构是长出来的——先用轻架构扛住一期,让真实的痛点(而不是技术理想)告诉你下一刀切哪里。
从零设计一个系统,最难的不是技术选型,而是在信息不全的情况下做出可辩护的决策——每个决策都经得起"为什么不用 X"的追问。至此全栈系列四篇:《一次点击背后》(怎么运转)→《深夜告警之后》(怎么排障)→《在告警响起之前》(怎么预测)→ 本篇(怎么从零设计)。想看"秒杀系统"或"账务系统"的专场推演,评论区点单。
参考与延伸阅读:
- 《Domain-Driven Design》——限界上下文(但记住:5 人团队不需要 10 个限界上下文)
- Google SRE Book: Designing for Reliability
- 《Designing Data-Intensive Applications》——存储选型的原理根基
- AWS Well-Architected Framework——非功能设计的清单化