系统架构 · 常见架构模式

积木的组合方式: 模式不是银弹, 每一种都在用复杂度换某个质量属性 — 先有症状, 再开药

常见架构模式 · CQRS / 事件溯源 / Saga / Sidecar — 积木的组合方式 模式不是银弹: 每一种都在用复杂度换某个质量属性 校验 append 订阅 物化 读 反例: 双写分叉 — 两份真相 ① CQRS + 事件溯源 — 写进去的是事实, 读出来的是视图 emerald 链: 事件日志是唯一事实源, 其余全是投影 命令 POST /orders 用户意图, 待校验 写模型 聚合 · 领域逻辑 Order.Place() 决策 事件日志 append-only 只追加 · 不改不删 投影 Projection 订阅事件 → 物化 坏了可重放重建 读模型 ES 宽表 · 物化视图 为查询形状而生 查询 GET /orders/1001 毫秒级 · 只读 同事务写 MySQL orders 真相①: 当前状态表 同时发 MQ → ES 建索引 真相②: 搜索视图 事故现场: 下单成功, MySQL 已落库, 但 MQ 发送超时 → ES 永远少这单; 对账脚本凌晨哀嚎: org.apache.kafka.common.errors.TimeoutException: Expiring 1 record(s) for product.changes-0:120000 ms has passed since batch creation 破局: 写模型只 append 事件; MySQL 状态与 ES 索引都是事件的投影 — 投影坏了重放重建, 数据永不错位 ② Saga — 把跨服务大事务拆成本地事务 + 反向补偿 不保证隔离性: 中间态对外可见, 需语义锁/计数器 ③ 失败 → 逆序补偿: 退库存(②) → 取消订单(①) OrderCreated StockReserved Paid 编排式 Orchestration — 中央编排器发号施令 OrderSaga 编排器 状态机 · 知道全局流程 ① 创建订单 本地事务 ② 扣库存 可补偿 ③ 扣款 易失败点 ④ 发货 末步无补偿 流程集中可见、可监控、可人工接管; 代价: 编排器是中心件, 别让它长出业务逻辑 协同式 Choreography — 只广播事件, 各自订阅 订单服务 发布事件 库存服务 StockReserved 支付服务 Paid 物流服务 Shipped 服务零耦合: 加订阅者不动别人; 代价: 流程散落, "订单走到哪了"要查事件链 补偿也靠事件: PaymentFailed → 库存服务订阅后自动回滚 ③ 接入分层 — 网关进流量, BFF 做聚合, Sidecar 管东西向 南北向 = 客户端进出 · 东西向 = 服务互调 接入层 · 南北向 领域服务 + 数据面 · 东西向 API Gateway 路由 · 限流 · 鉴权 · TLS BFF 按端聚合裁剪 · GraphQL 订单服务 交易上下文 支付服务 支付上下文 库存服务 库存上下文 每个 Pod 旁挂一个 Sidecar (Envoy): mTLS · 重试 · 熔断 · 遥测 — 东西向互调全走它, 业务零感知 ● emerald = 正确路径(唯一事实源 / 正常编排) · rose 虚线 = 事故路径(双写分叉 / 逆序补偿) · orange = 消息与投影 · cyan = 入口 · violet = 数据 读法: 上 = 数据怎么流(写进事实 · 读出视图) · 中 = 流程怎么保(两式 Saga) · 下 = 流量怎么进(分层接入) — 模式是积木, 不是勋章

结构视角 — 三张图一套积木

  • • 数据结构: CQRS 把一份数据拆成写模型(保不变式) + 读模型(为查询而生)
  • • 事实结构: 事件溯源的 append-only 日志是唯一事实源, 状态是投影
  • • 通信结构: 网关 / BFF / Sidecar 决定流量怎么进出、横切逻辑放哪
  • • 一致性结构: Saga 用"本地事务 + 补偿"替代分布式大事务

行为视角 — 每种模式都在付费

  • • 微服务: 用网络调用的延迟与部分失败, 换独立发布与独立扩容
  • • 事件溯源: 用存储与回放成本, 换完整审计与"时间旅行"
  • • Saga: 用最终一致的中间态, 换跨服务流程的可用性
  • • 投影必然滞后: "写后立刻读"要专门设计, 否则就是客诉

生产价值 — 选型的克制

  • • 先模块化单体练边界, 再谈拆服务 — 拆得动的单体是微服务前身
  • • 按症状开药: 读压山大→CQRS; 要时间旅行→事件溯源; 跨服务流程→Saga
  • • 网关是入口不是业务的家; BFF 有主人才不会变成公共坟场
  • • 服务数不是 KPI, 发布频率 / MTTR / P99 才是

💡 一句话理解

微服务、CQRS、事件溯源、Saga、Sidecar——听着像五门课, 其实是同一个动作: 把一坨"什么都管"的代码拆成各管一摊的积木。拆完换来什么? 订单和库存能独立发布(微服务), 账本能回放到任意历史时刻(事件溯源), 读写各自选型(CQRS), 跨服务流程失败能按补偿退回(Saga), 业务进程不用再操心重试和证书(Sidecar)。

但每种模式都明码标价: 函数调用变网络调用(延迟与部分失败), 一份数据变两副面孔(投影滞后), 当前状态变事件回放(存储与复杂度), 本地事务变补偿流程(最终一致), 一个进程变两个进程(资源翻倍)。模式不是 KPI, 是处方式的药——先有症状(读压山大 / 要完整审计 / 跨服务流程), 再开药; 没症状时, 模块化单体是被低估最多的处方。

🧠 必知必会 必考 & 必会

Modular Monolith 模块化单体
单进程部署 + 包级强边界: 模块间只许走公开接口, 数据库表互不跨模块直查。它保留了"拆得动"的可能——边界清晰的单体, 拆微服务是一次搬家; 糊成一团的单体, 拆微服务是搬家时才发现行李粘在一起。
internal/
  orders/   orders.Service   // 对外只有这个接口 + DTO
  billing/  billing.Service  // 只 import orders 接口, 不碰它的表
// 关键: 先划包边界再谈拆服务 — 拆得动的单体是微服务前身
Microservices 微服务
按业务能力拆成独立部署的小服务, 换来独立发布 / 独立扩容 / 技术异构, 付出分布式的全部税: 网络延迟、部分失败、数据一致、链路排障。判断标准不是"代码多少", 而是"团队边界与发布节奏是否真的不同"。
err := payClient.Charge(ctx, req)  // 拆一次 = 多一种失败模式
// 下单服务从此要直面支付服务的超时 / 5xx / 网络分区
// 关键: 拆的是组织与发布节奏, 不是"代码看着乱"
API Gateway 网关
系统唯一入口: 路由、限流、认证、TLS 卸载在入口统一做, 微服务退回"只管业务"。代价: 它是全站单点必须双活, 也是最容易变"新单体"的地方——业务逻辑进网关之日, 就是改个路由要过三个团队评审之时。
# APISIX: uri + upstream + 插件, 微服务零感知
routes:
- uri: /api/orders/*
  upstream_id: orders
  plugins: {limit-req: {rate: 200, burst: 100, key: remote_addr}}
# 关键: 网关只做横切面, 出现业务 if = 越界
BFF Backend for Frontend
为每种前端配一个后端: App-BFF 懂移动端的"一屏多块", Web-BFF 懂桌面端; 聚合多个微服务响应、裁剪字段、做端侧适配, 常用 GraphQL 让前端"要多少拿多少"。代价: BFF 数量 = 端数量, 只增不减, 必须有人认领。
query OrderPage($id: ID!) {
  order(id: $id) {      # 一次查询 = 聚合 订单+用户+物流 三域
    amount status
    user { nickname }
  }
}
# 关键: BFF 的价值是"端要什么就聚合什么", 不是第四层转发
Sidecar Pattern 边车
每个业务 Pod 旁挂一个代理进程(Envoy), TLS / 重试 / 熔断 / 遥测全部下沉到边车, 业务代码零侵入。代价: 每个实例多一份 CPU/内存与一跳代理延迟(约 0.1~1ms), 海量小实例时边车开销可观(解法见 ambient mesh)。
# Istio: 重试/熔断写在这里, Go 代码里没有一行 retry
# VirtualService:
http:
- retries: {attempts: 3, perTryTimeout: 2s, retryOn: "5xx,connect-failure"}
# 关键: 横切逻辑进 Sidecar, 业务进程只管业务
Event-driven 事件驱动
服务间不直接调用, 而是"发生了什么"广播到 broker, 感兴趣的各自订阅。耦合从"我要调用你"降到"我知道这个事实", 加新消费者不动生产者。代价: 时序难推理(乱序/重复), 排障从看栈帧变成翻 topic。
kafka-topics.sh --create --topic orders.events \
  --partitions 12 --config cleanup.policy=compact
# 关键: compact 保留每个 key 的最新事件 — 可当"当前状态"用
CQRS 命令查询职责分离
写模型保证不变式(聚合校验), 读模型为查询形状定制(ES 宽表 / 缓存), 两者靠事件同步, 天然最终一致。读多写少且读形状复杂的系统(搜索 / 报表 / feed)收益最大; CRUD 小系统上 CQRS 是纯负担。
// 写: Order.Place() 校验库存/状态 → 产生 OrderPlaced 事件
// 读: projection 把事件物化进 ES 的 order_view 宽表
// 关键: 写模型答"这样对不对", 读模型答"怎么查得快"
Event Sourcing 事件溯源
不存"当前余额=100", 而存全部事件[+500, -200, -100...], 当前状态 = 按序折叠(fold)全部事件。换来完整审计与"时间旅行"(任意时刻状态), 付出存储与重放成本——EventStoreDB 是原生实现, Kafka compact topic 也能客串事件存储, 但都得配聚合重建。
balance := 0
for _, e := range events { balance += e.Amount }  // fold
// 关键: [开+500, 消费-200, 退款-100] 折出 balance = 200
// 状态是事件的因变量 — append-only 日志才是事实
Snapshot 快照
事件溯源的性能阀: 每累计 N(常见 100~1000)个事件存一次聚合快照, 重建 = 最近快照 + 之后的事件, 复杂度从 O(全部事件) 降到 O(增量)。没有快照的聚合跑三年, 一次回放就能把服务打挂。
if agg.Version%100 == 0 {
    saveSnapshot(agg.ID, agg.Version, agg.State) // 每 100 个事件存一次
}
// 关键: 回放 = 最近快照 + 增量事件, 不再从恐龙时代重放
Saga
跨服务大事务拆成本地事务序列, 每步配一个补偿动作; 任何一步失败, 逆序执行已完成步骤的补偿。两种编排: orchestration(中央编排器, 流程清晰)与 choreography(事件链, 服务零耦合)。它不保证隔离性——中间状态对外可见, 要配语义锁。
steps := []SagaStep{
    {Do: createOrder, Undo: cancelOrder},
    {Do: deductStock, Undo: restoreStock},
    {Do: charge,      Undo: refund},   // 关键: 每步必配补偿且幂等
}  // 失败即逆序 Undo: 扣款失败 → 退库存 → 取消订单
Hexagonal 六边形架构
领域逻辑在中心, 通过 Port(接口) 对外, DB / MQ / 第三方 SDK 都是可替换的 Adapter。价值在防腐层: 第三方支付 SDK 升级大改, 只换 adapter, 领域层零改动。代价是接口转发的样板代码——没有变化维度的抽象就是纯税。
type PaymentGateway interface {  // Port: 领域层定义的口
    Charge(ctx context.Context, p Payment) (TxnID, error)
}
// 关键: 领域层只认识接口; Stripe/PayPal 都只是插进来的 adapter
Bounded Context 限界上下文
DDD 拆微服务的刀: 同一个词在不同上下文含义不同——"商品"在交易上下文有价格库存, 在物流上下文只有重量体积。上下文边界 = 模型边界 = 服务边界; 按技术层(controller/service/dao)拆服务是最常见也最痛的错法。
type Order struct { Amount int; Status string }   // 交易上下文: 关心钱
type Order struct { Address string; Parcels int } // 物流上下文: 关心地址
// 关键: 上下文不同, 模型不同, 各自独立演进
Plug-in 插件化架构
把"会新增的种类"做成扩展点: 核心只认接口 + 注册表, 新格式 / 新渠道 = 新插件注册, 不改核心代码(开闭原则的架构版)。代价: 扩展点接口必须稳定——扩展点设计错了, 插件比核心还难改。
var exporters = map[string]func() Exporter{}  // 注册表
exporters["csv"] = func() Exporter { return CSVExporter{} }
// 新增 parquet: 注册一行, 核心零改动
// 关键: 扩展点 = 会变化的维度被接口圈起来

🏭 生产实战 real world

场景 1 · 32 个服务的接入统一到 APISIX: 路由 / 限流 / 灰度一处搞定

以前每个服务自带一份 nginx + 各自限流, 出入口策略对不齐。网关统一后, 南北向策略一处声明、全站生效。

# APISIX 路由: 认证、限流、灰度在网关一层做完, 微服务只管业务
routes:
- uri: /api/orders/*
  upstream_id: orders-upstream
  plugins:
    jwt-auth: {}                 # 验签失败 → 401, 流量不进业务
    limit-req:
      rate: 500                  # 令牌桶平滑速率 (r/s)
      burst: 200                 # 允许 200 瞬时突发
      key_type: var
      key: remote_addr
      rejected_code: 429         # 超限明说, 别装死
# upstreams:
# - id: orders-upstream
#   nodes: {"10.0.0.1:8080": 95, "10.0.0.9:8080": 5}   # 5% 灰度 v2

新服务接入从"改 5 处配置"变成"加一条路由", 出入口策略首次有了单一事实源。

场景 2 · 商品详情读 QPS 是写的 400 倍: MySQL 写 + ES 读的 CQRS 落地

写走 MySQL 保事务与不变式, 查走 ES 支撑多条件聚合搜索; 中间用 binlog 变更做投影, 写侧是唯一写入点。

# 投影链路: MySQL binlog → Kafka → indexer → ES (写 MySQL 是唯一写入点)
# 1) MySQL binlog ROW 格式; 2) Canal/Debezium 订阅变更投 product.changes
# 3) indexer 消费并物化宽表:
PUT product_view
{ "mappings": { "properties": {
    "title":    { "type": "text", "analyzer": "ik_max_word" },
    "price":    { "type": "scaled_float", "scaling_factor": 100 },
    "sales_7d": { "type": "integer" } } } }
# 关键: 改索引结构不用停写 — 投影只是变更的另一种物化, 删了重建即可

详情页聚合查询 P99 从 MySQL 的 1.2s 降到 ES 的 45ms, 写侧零改动。

场景 3 · 银行对账要"任意时刻账户状态": 事件溯源重建聚合

可疑交易要回放案发时刻的余额——当前状态表做不到, 事件日志一行 fold 就出来。

type Event struct {
    At     time.Time
    Kind   string  // open | deposit | withdraw | fee | refund
    Amount int64   // 分
}

// 重建任意时点 t 的余额: 只回放 At <= t 的事件 (日志按时间有序)
func balanceAt(events []Event, t time.Time) int64 {
    var bal int64
    for _, e := range events {
        if e.At.After(t) { break }  // 越过时点即停
        switch e.Kind {
        case "withdraw", "fee":
            bal -= e.Amount
        default:
            bal += e.Amount
        }
    }
    return bal  // → balanceAt(23:59:59) = 案发前一秒的精确余额
}

审计与合规直接复用这条路径: 任意时点、任意账户, 秒级出账。

场景 4 · 跨境单 Saga: 订单-库存-支付三步, 失败自动逆向退款

分布式大事务拆成三个本地事务, 每步必配补偿; 编排器落库 saga_log, 卡住的流程可人工接管。

// 编排式 Saga: 步骤表驱动, 每步必配补偿(补偿必须幂等)
var flow = []Step{
    {"create_order", createOrder, cancelOrder},
    {"hold_stock",   holdStock,   releaseStock},
    {"charge_usd",   chargeUSD,   refundUSD},
}
func (s *Orchestrator) Run(ctx context.Context, o *OrderCtx) error {
    for i, st := range flow {
        if err := st.Do(ctx, o); err != nil {
            for j := i - 1; j >= 0; j-- {       // 逆序补偿
                _ = flow[j].Undo(ctx, o)      // 补偿失败进死信, 人工接管
            }
            return fmt.Errorf("saga failed at %s: %w", st.Name, err)
        }
        s.log.Step(o.ID, st.Name, "done")     // saga_log 落库, 全程可观测
    }
    return nil
}

上线后"卡单"从每天 40+ 笔人工处理降到个位数, 且每一笔都能查到停在哪个补偿。

场景 5 · App 首页一屏要调 4 个接口: BFF + GraphQL 一次聚合

移动端弱网 4 次 RTT 才能凑齐一屏。App-BFF 聚合并裁剪字段, 响应从 38KB 瘦到 6KB。

# App-BFF: 首页聚合 订单/用户/物流/推荐 四个域, 一次 RTT
query HomePage($uid: ID!) {
  user(id: $uid) { nickname avatarUrl }          # 裁剪: 不要 email/birth
  pendingOrders(uid: $uid, limit: 3) { id amount }
  shipmentTrace(uid: $uid) { carrier eta }
  feed(limit: 10) { skuId title price }          # 推荐只要展示字段
}
# 各解析器并发取数, 总耗时 ≈ max(四个下游) 而非求和
# 上线后: 首页请求数 4→1, P75 首屏耗时 1.9s → 0.8s

场景 6 · 库存服务一抖全站陪葬? Sidecar 接管重试 / 熔断, 业务零代码

手写的 retry 库各服务一份、行为不一致。下沉到 Istio 数据面, Go 代码里删掉所有 retry。

# Istio: DestinationRule 熔断 + VirtualService 重试 (数据面生效)
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata: {name: stock, namespace: prod}
spec:
  host: stock.prod.svc.cluster.local
  trafficPolicy:
    connectionPool:
      http: {http1MaxPendingRequests: 200}
    outlierDetection:              # 连续 5xx 的实例踢出去 30s
      consecutive5xxErrors: 5
      interval: 10s
      baseEjectionTime: 30s
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata: {name: stock, namespace: prod}
spec:
  http:
  - retries: {attempts: 2, perTryTimeout: 300ms, retryOn: "connect-failure"}
    timeout: 1s

库存抖动时上游只慢 1s 就快速失败, 线程池不再被拖满, 故障半径锁回库存服务自己。

场景 7 · 第三方支付 SDK 一年改三次, 领域层零改动: 六边形防腐层

领域服务直接 import Stripe SDK, 升级一次改一片。收进 adapter 后, SDK 怪癖再也出不了这层。

// Port: 领域层定义, 不 import 任何第三方包
type PaymentGateway interface {
    Charge(ctx context.Context, p Payment) (TxnID, error)
}

// Adapter: 包住 StripeSDK 的所有怪癖, 领域层看不见
type stripeAdapter struct{ cli *stripe.Client }

func (a *stripeAdapter) Charge(ctx context.Context, p Payment) (TxnID, error) {
    ch, err := a.cli.PaymentIntents.Create(&stripe.PaymentIntentParams{
        Amount:   stripe.Int64(p.Cents),       // 单位换算在 adapter 内消化
        Currency: stripe.String(p.Currency),
    })
    if err != nil { return "", translateErr(err) } // SDK 错误 → 领域错误
    return TxnID(ch.ID), nil
}
// 换 PayPal: 新写 paypalAdapter, 领域服务一行不改

场景 8 · 20 人团队的仓库系统: 模块化单体先行, 半年后拆服务毫不费力

没追微服务风潮, 先用 Go 的包边界练内功——拆分当天只是把 internal/orders 挪进独立 main.go。

// 目录即边界: 跨模块只许 import 对方根包的 Service 接口
/internal
  /orders    orders.Service   // 对外接口 + DTO
    /repo     (私有; 别的模块 import 会被 CI 门禁直接拦下)
  /billing   billing.Service
  /shipping  shipping.Service
// 迁移日: internal/orders 原样搬进独立仓库, 接口换成 gRPC 定义
// 关键: 模块间"编译期强边界"是拆服务的全部前置条件

半年后按业务需要拆出物流服务, 因为边界早已清晰, 迁移只花了三天。

场景 9 · 报表导出一个月加三种格式: 注册表插件化, 核心零改动

导出格式从 CSV 长到 Excel / PDF / Parquet, 每次都改核心 if-else。抽成注册表后, 新格式 = 一个新文件。

// 扩展点: 核心只认 Exporter 接口 + 注册表
type Exporter interface {
    ContentType() string
    Write(w io.Writer, rows <-chan Row) error
}
var registry = map[string]Exporter{}          // 格式名 → 实现

func Register(name string, e Exporter) {      // 各插件 init() 里自注册
    if _, dup := registry[name]; dup { panic("exporter dup: " + name) }
    registry[name] = e
}
// 新增 parquet = 新文件 parquet.go + init(){Register("parquet",...)}
// 核心路由代码从上线那天起没再改过一行

场景 10 · "订单"到底归谁管? DDD 限界上下文定服务边界

交易、物流、售后都要"订单", 却要完全不同的字段与生命周期——按上下文拆库拆服务, 跨上下文靠 ID 引用 + 事件同步。

-- 同一个词, 两个上下文两张表, 字段互不迁就
CREATE TABLE trading.orders (
  id BIGINT PRIMARY KEY, buyer_id BIGINT,
  amount_cents INT, pay_status VARCHAR(16)   -- 交易关心钱
);
CREATE TABLE logistics.orders (
  id BIGINT PRIMARY KEY, trading_order_id BIGINT,
  receiver_addr VARCHAR(256), parcels INT     -- 物流关心地址件数
);
-- 交易下单成功 → OrderPaid 事件 → 物流上下文落自己的表
-- 关键: 服务边界 = 上下文边界, 不存在"一张大订单表管所有业务"

此后"改物流字段要过交易团队评审"的事故再没发生过——两个上下文各自发布。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 为拆而拆的"分布式单体" — 症状: 拆了 15 个服务, 任何一个需求要同时改 8 个仓库同时发版. 原因: 按技术层/图省事拆分, 服务间隐式强耦合, 只是物理分开逻辑没分. 正解: 按业务能力/限界上下文拆, 先用模块化单体练边界。
// 错: controller-svc / service-svc / dao-svc 三个"服务"
//    一个字段变更 → 三仓同改同发, 还多了网络开销
// 对: 按"交易/物流/售后"拆 — 各自的需求落在各自的服务里
坑 2 · 微服务共享一个数据库 — 症状: "拆了服务", 库存 SQL 却被 6 个服务 join. 原因: 表共享 = schema 耦合, 任何人改表结构都是全站事故. 正解: 一服务一库, 跨服务要数据走 API/事件, 不许直连别人的库。
-- 错: 六个服务直连同一个 orders 库互相 join
--     → 改一列, 六个团队连夜排查
-- 对: 一服务一库; 跨库数据用事件同步成本地只读副本
坑 3 · 读少写少也上 CQRS — 症状: 日活 300 的内部系统被拆成 写模型+事件+投影+读模型 四件套. 原因: 把模式当简历加分项. 正解: 读压不大/查询形状简单 → 单库足够, CQRS 的复杂度没有买单人。
// 错: 日活 300 的审批系统上 CQRS → 投影滞后比请求还慢
// 对: 一张 MySQL 表 + 两个索引, 需求当天上线
坑 4 · 事件溯源当审计日志用(缺聚合重建) — 症状: "我们存了全部事件", 但没人能把账户当前状态算出来. 原因: 只 append 不定义聚合与 fold, 事件日志成了没人读的账本, 真相还另有"状态表". 正解: 先定义聚合与重建逻辑(快照兜底), 事件才有溯源价值。
-- 错: 只 append 不读, 当前状态另有"真相表" → 两处真相互相打脸
-- 对: fold(events) == 聚合状态, 快照兜底, 审计与状态同源
坑 5 · Saga 步骤硬编码, 牵一发动全身 — 症状: 新增一步"风控审核"要改编排器核心 + 全量回归 + 停机发版. 原因: 步骤数组写死在代码里, 无版本无配置. 正解: 步骤定义配置化 + 版本号, 存量单按老版本走完。
// 错: flow = []Step{...写死...} → 加一步 = 改代码 + 全回归
// 对: 步骤从 saga_def 表按版本读, 存量单按老版本执行到终点
坑 6 · 网关塞业务逻辑变成新单体 — 症状: 改个优惠计算规则要等网关团队的发布窗口. 原因: 业务 if 进了网关插件, 网关成了公共变更瓶颈. 正解: 网关只做路由/限流/鉴权等横切面, 业务逻辑归服务。
# 错: 网关插件里 if amount > 100 then discount → 业务发布全堵在网关
# 对: 网关只有路由/限流/鉴权; 优惠计算在订单服务, 独立发版
坑 7 · BFF 层数量爆炸没人维护 — 症状: App/Web/小程序/出海 四个 BFF, 一半没人敢动. 原因: 每端各起一层又无归属团队, 渐成公共坟场. 正解: BFF 数量收敛, 由前端团队 owns 自己的 BFF, 复用逻辑下沉公共 resolver。
// 错: 8 个 BFF × 平均 0.5 个维护人 → 没人知道哪个还能删
// 对: 每个 BFF 有明确 owning 团队, 公共聚合逻辑下沉 resolver 库
坑 8 · Sidecar 资源开销翻倍 — 症状: 500 个小 Pod 的集群, 数据面吃掉 40% CPU. 原因: 每个 Pod 一份 Envoy, 小实例业务可能比边车还轻. 正解: 评估实例规格与规模, 大规模小实例改用 ambient mesh(每 node 一个 ztunnel)。
# 错: 0.25 核业务 Pod × 0.5 核 Envoy → 数据面比业务还贵
# 对: ambient mesh: ztunnel 每 node 一个, 边车开销降一个量级
坑 9 · 六边形为抽象而抽象 — 症状: 一个 GET 接口穿过 Controller→UseCase→Domain→Port→Adapter 五层转发. 原因: 模板化套用分层, 没有变化维度的抽象=纯税. 正解: 只给"确定会换"的依赖(支付/存储)建 Port, 简单读写直来直去。
// 错: 查个配置也 Domain→Port→Adapter 三层 → 200 行样板转发
// 对: 只给 PaymentGateway/Repo 这类"必然换实现"的口建 Port
坑 10 · 事件 schema 演进破坏消费者 — 症状: 上游加个字段, 12 个消费组同时挂. 原因: 没有兼容策略, 消费者对未知字段/类型变更零容忍. 正解: 只加不删(Tolerant Reader), schema 进注册中心强制兼容性校验。
// 错: 事件 struct 直接删字段/改名 → 旧消费者反序列化崩
// 对: 新增字段不删旧字段; 消费端忽略未知字段(向前兼容)
坑 11 · 微服务间同步调用链过长 — 症状: 一次下单串行调 6 个服务, 任一抖动全链路 P99 飙升. 原因: 该异步的编排做成了同步接力. 正解: 强依赖拆解——能事件化的事件化, 必须同步的并行化 + 超时预算递减。
// 错: 下单同步串行调 风控→库存→积分→通知→推荐 (5 跳)
// 对: 同步只留 风控/库存; 积分/通知/推荐全走事件异步
坑 12 · 事件乱序消费未按聚合 id 排序 — 症状: "已取消"事件先于"已支付"被消费, 订单状态倒挂. 原因: Kafka 只保证分区内有序, 重试/多线程消费打破顺序. 正解: 按聚合 key 分区 + 事件带 seq, 消费端丢弃过期序号。
// 错: 多 worker 抢同一分区消息 → Paid 与 Cancelled 乱序到账
// 对: key=orderID 保单分区有序 + 事件带 seq, seq ≤ 已见即跳过
坑 13 · 投影滞后查不到刚写的单 — 症状: 用户下单成功, 列表页却查不到, 客诉进线. 原因: 读模型异步投影有秒级滞后, 被"写后立刻读"精准命中. 正解: 写后读走写模型(read-your-write), 详情页读主、列表页读投影。
// 错: 下单 → 立刻 GET /orders → 打到 ES → 查无此单
// 对: 下单响应直接返回详情(写模型); 列表页才走读模型
坑 14 · 网关单点无备份 — 症状: APISIX 一台故障, 全站不可访问. 原因: 单实例部署, 没有多副本 + 前置 LB + 配置同步. 正解: 网关至少 2 副本 + LB 前置 + 配置中心(etcd)多副本同步。
# 错: 单节点 APISIX → 它挂全站挂
# 对: 2+ 副本前置 LB, 配置存 etcd 多副本自动同步
坑 15 · 插件热加载崩进程 — 症状: 运行时加载新插件, 一个 panic 拖垮主进程. 原因: 插件与核心同进程同地址空间, nil 解引用全完. 正解: 新插件走独立二进制滚动发布, 或独立容器/sidecar 隔离, 不做同进程热更。
// 错: 运行时 dlopen 插件 so → 插件 panic 主进程陪葬
// 对: 新插件 = 新版本滚动发布; 或独立容器隔离崩溃面
坑 16 · 跨服务 join 拼装 N+1 — 症状: 订单列表 50 条, 触发 50 次用户服务调用, P99 3s. 原因: 循环内逐条 RPC. 正解: 批量接口 IN(ids) 一次取 + 短 TTL 本地缓存。
// 错: for _, o := range orders { u = userCli.Get(o.UID) } // 50 次 RPC
// 对: ids := collectUIDs(orders); users = userCli.Batch(ids) // 1 次
坑 17 · 领域边界按技术层划分 — 症状: "web-svc / biz-svc / dao-svc"三个服务, 一个需求改三处. 原因: 用技术分层当了业务边界. 正解: 边界跟业务能力走(交易/物流/库存), 技术层留在服务内部。
// 错: web-svc / biz-svc / dao-svc → 需求永远三仓齐改
// 对: trading / logistics / inventory 按业务能力切, 层在服务内
坑 18 · 事件存储不做快照, 回放太慢 — 症状: 聚合三年攒了 40 万事件, 一次 loadAgg 要 8 秒. 原因: 每次从第 1 个事件全量回放. 正解: 每 N 个事件定期快照, 回放 = 快照 + 增量; 快照带版本校验防脏。
// 错: loadAgg(id) → 回放 400,000 个事件, 8.2s
// 对: snapshot(v=399,900) + 回放 100 条增量 → 12ms
坑 19 · 新旧模式并存无迁移计划 — 症状: 半年了还同时维护单体老链路 + CQRS 新链路, 双写双读对不齐. 原因: 重构没有终点判据与绞杀顺序. 正解: 绞杀者模式按路由逐功能切流, 每段"旧路径下线"有明确验收与 deadline。
// 错: 新链路上线后旧代码"先留着" → 双真相永久共存
// 对: strangler: 按 URI 逐段切流, 100% 后旧代码删除 + 测试下线
坑 20 · 把模式当 KPI 强推全团队 — 症状: OKR 写"全年微服务化 80%", 团队把 20 个单体硬拆出 20 个分布式单体. 原因: 用架构形态当绩效目标, 忘了模式是为质量属性服务的手段. 正解: KPI 定质量属性(发布频率/MTTR/P99), 手段按需选——包括"不拆"。
// 错: KPI: 服务数 ≥ 30 → 造出 30 个分布式单体
// 对: KPI: 发布频率 / MTTR / P99 — 为达指标自由选型(含不拆)