积木的组合方式: 模式不是银弹, 每一种都在用复杂度换某个质量属性 — 先有症状, 再开药
微服务、CQRS、事件溯源、Saga、Sidecar——听着像五门课, 其实是同一个动作: 把一坨"什么都管"的代码拆成各管一摊的积木。拆完换来什么? 订单和库存能独立发布(微服务), 账本能回放到任意历史时刻(事件溯源), 读写各自选型(CQRS), 跨服务流程失败能按补偿退回(Saga), 业务进程不用再操心重试和证书(Sidecar)。
但每种模式都明码标价: 函数调用变网络调用(延迟与部分失败), 一份数据变两副面孔(投影滞后), 当前状态变事件回放(存储与复杂度), 本地事务变补偿流程(最终一致), 一个进程变两个进程(资源翻倍)。模式不是 KPI, 是处方式的药——先有症状(读压山大 / 要完整审计 / 跨服务流程), 再开药; 没症状时, 模块化单体是被低估最多的处方。
internal/ orders/ orders.Service // 对外只有这个接口 + DTO billing/ billing.Service // 只 import orders 接口, 不碰它的表 // 关键: 先划包边界再谈拆服务 — 拆得动的单体是微服务前身
err := payClient.Charge(ctx, req) // 拆一次 = 多一种失败模式 // 下单服务从此要直面支付服务的超时 / 5xx / 网络分区 // 关键: 拆的是组织与发布节奏, 不是"代码看着乱"
# APISIX: uri + upstream + 插件, 微服务零感知 routes: - uri: /api/orders/* upstream_id: orders plugins: {limit-req: {rate: 200, burst: 100, key: remote_addr}} # 关键: 网关只做横切面, 出现业务 if = 越界
query OrderPage($id: ID!) {
order(id: $id) { # 一次查询 = 聚合 订单+用户+物流 三域
amount status
user { nickname }
}
}
# 关键: BFF 的价值是"端要什么就聚合什么", 不是第四层转发# Istio: 重试/熔断写在这里, Go 代码里没有一行 retry # VirtualService: http: - retries: {attempts: 3, perTryTimeout: 2s, retryOn: "5xx,connect-failure"} # 关键: 横切逻辑进 Sidecar, 业务进程只管业务
kafka-topics.sh --create --topic orders.events \ --partitions 12 --config cleanup.policy=compact # 关键: compact 保留每个 key 的最新事件 — 可当"当前状态"用
// 写: Order.Place() 校验库存/状态 → 产生 OrderPlaced 事件 // 读: projection 把事件物化进 ES 的 order_view 宽表 // 关键: 写模型答"这样对不对", 读模型答"怎么查得快"
balance := 0 for _, e := range events { balance += e.Amount } // fold // 关键: [开+500, 消费-200, 退款-100] 折出 balance = 200 // 状态是事件的因变量 — append-only 日志才是事实
if agg.Version%100 == 0 { saveSnapshot(agg.ID, agg.Version, agg.State) // 每 100 个事件存一次 } // 关键: 回放 = 最近快照 + 增量事件, 不再从恐龙时代重放
steps := []SagaStep{
{Do: createOrder, Undo: cancelOrder},
{Do: deductStock, Undo: restoreStock},
{Do: charge, Undo: refund}, // 关键: 每步必配补偿且幂等
} // 失败即逆序 Undo: 扣款失败 → 退库存 → 取消订单type PaymentGateway interface { // Port: 领域层定义的口 Charge(ctx context.Context, p Payment) (TxnID, error) } // 关键: 领域层只认识接口; Stripe/PayPal 都只是插进来的 adapter
type Order struct { Amount int; Status string } // 交易上下文: 关心钱 type Order struct { Address string; Parcels int } // 物流上下文: 关心地址 // 关键: 上下文不同, 模型不同, 各自独立演进
var exporters = map[string]func() Exporter{} // 注册表 exporters["csv"] = func() Exporter { return CSVExporter{} } // 新增 parquet: 注册一行, 核心零改动 // 关键: 扩展点 = 会变化的维度被接口圈起来
以前每个服务自带一份 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 处配置"变成"加一条路由", 出入口策略首次有了单一事实源。
写走 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, 写侧零改动。
可疑交易要回放案发时刻的余额——当前状态表做不到, 事件日志一行 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) = 案发前一秒的精确余额 }
审计与合规直接复用这条路径: 任意时点、任意账户, 秒级出账。
分布式大事务拆成三个本地事务, 每步必配补偿; 编排器落库 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+ 笔人工处理降到个位数, 且每一笔都能查到停在哪个补偿。
移动端弱网 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
手写的 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 就快速失败, 线程池不再被拖满, 故障半径锁回库存服务自己。
领域服务直接 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, 领域服务一行不改
没追微服务风潮, 先用 Go 的包边界练内功——拆分当天只是把 internal/orders 挪进独立 main.go。
// 目录即边界: 跨模块只许 import 对方根包的 Service 接口 /internal /orders orders.Service // 对外接口 + DTO /repo (私有; 别的模块 import 会被 CI 门禁直接拦下) /billing billing.Service /shipping shipping.Service // 迁移日: internal/orders 原样搬进独立仓库, 接口换成 gRPC 定义 // 关键: 模块间"编译期强边界"是拆服务的全部前置条件
半年后按业务需要拆出物流服务, 因为边界早已清晰, 迁移只花了三天。
导出格式从 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",...)} // 核心路由代码从上线那天起没再改过一行
交易、物流、售后都要"订单", 却要完全不同的字段与生命周期——按上下文拆库拆服务, 跨上下文靠 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 事件 → 物流上下文落自己的表 -- 关键: 服务边界 = 上下文边界, 不存在"一张大订单表管所有业务"
此后"改物流字段要过交易团队评审"的事故再没发生过——两个上下文各自发布。
// 错: controller-svc / service-svc / dao-svc 三个"服务" // 一个字段变更 → 三仓同改同发, 还多了网络开销 // 对: 按"交易/物流/售后"拆 — 各自的需求落在各自的服务里
-- 错: 六个服务直连同一个 orders 库互相 join -- → 改一列, 六个团队连夜排查 -- 对: 一服务一库; 跨库数据用事件同步成本地只读副本
// 错: 日活 300 的审批系统上 CQRS → 投影滞后比请求还慢 // 对: 一张 MySQL 表 + 两个索引, 需求当天上线
-- 错: 只 append 不读, 当前状态另有"真相表" → 两处真相互相打脸 -- 对: fold(events) == 聚合状态, 快照兜底, 审计与状态同源
// 错: flow = []Step{...写死...} → 加一步 = 改代码 + 全回归 // 对: 步骤从 saga_def 表按版本读, 存量单按老版本执行到终点
# 错: 网关插件里 if amount > 100 then discount → 业务发布全堵在网关 # 对: 网关只有路由/限流/鉴权; 优惠计算在订单服务, 独立发版
// 错: 8 个 BFF × 平均 0.5 个维护人 → 没人知道哪个还能删 // 对: 每个 BFF 有明确 owning 团队, 公共聚合逻辑下沉 resolver 库
# 错: 0.25 核业务 Pod × 0.5 核 Envoy → 数据面比业务还贵 # 对: ambient mesh: ztunnel 每 node 一个, 边车开销降一个量级
// 错: 查个配置也 Domain→Port→Adapter 三层 → 200 行样板转发 // 对: 只给 PaymentGateway/Repo 这类"必然换实现"的口建 Port
// 错: 事件 struct 直接删字段/改名 → 旧消费者反序列化崩 // 对: 新增字段不删旧字段; 消费端忽略未知字段(向前兼容)
// 错: 下单同步串行调 风控→库存→积分→通知→推荐 (5 跳) // 对: 同步只留 风控/库存; 积分/通知/推荐全走事件异步
// 错: 多 worker 抢同一分区消息 → Paid 与 Cancelled 乱序到账 // 对: key=orderID 保单分区有序 + 事件带 seq, seq ≤ 已见即跳过
// 错: 下单 → 立刻 GET /orders → 打到 ES → 查无此单 // 对: 下单响应直接返回详情(写模型); 列表页才走读模型
# 错: 单节点 APISIX → 它挂全站挂 # 对: 2+ 副本前置 LB, 配置存 etcd 多副本自动同步
// 错: 运行时 dlopen 插件 so → 插件 panic 主进程陪葬 // 对: 新插件 = 新版本滚动发布; 或独立容器隔离崩溃面
// 错: for _, o := range orders { u = userCli.Get(o.UID) } // 50 次 RPC // 对: ids := collectUIDs(orders); users = userCli.Batch(ids) // 1 次
// 错: web-svc / biz-svc / dao-svc → 需求永远三仓齐改 // 对: trading / logistics / inventory 按业务能力切, 层在服务内
// 错: loadAgg(id) → 回放 400,000 个事件, 8.2s // 对: snapshot(v=399,900) + 回放 100 条增量 → 12ms
// 错: 新链路上线后旧代码"先留着" → 双真相永久共存 // 对: strangler: 按 URI 逐段切流, 100% 后旧代码删除 + 测试下线
// 错: KPI: 服务数 ≥ 30 → 造出 30 个分布式单体 // 对: KPI: 发布频率 / MTTR / P99 — 为达指标自由选型(含不拆)