跳到正文

单元化架构 ​

一句话:微服务拆的是代码,单元化拆的是数据——按客户把应用、数据库和路由圈成自包含的单元,用跨单元最终一致换故障隔离和线性扩容。

适用:Nginx + Lua、MySQL 8.0+、RocketMQ 5.x;文中的 60/500 个单元、亿级映射是方案示例,不是实测指标。Kubernetes 侧的部署与单元隔离见 05-企业级K8s核心特性。

为什么需要 ​

微服务把应用拆开,但数据仍在一个大库里。数据量上来后只能分库分表,跨库 JOIN 和分布式事务越来越难;更麻烦的是故障半径——一个库或一个机房出问题,所有客户都受影响。

单元化把「一个客户的所有数据和处理它的服务」放进同一个单元。单元内是本地事务,单元间尽量不调用;一个单元故障只影响该单元的客户,扩容时加单元而不是无限加机器。

核心机制 ​

单元定义:分片键决定边界 ​

一个单元通常包含:应用实例、独立数据库、缓存、以及本单元的局部路由信息。它不是简单的「数据分片」,而是应用和数据的共同边界。

分片键选客户号,原因是:

  • 稳定:客户号不随卡、账户、手机号变化。
  • 聚合:账户、卡、交易都归属于客户,查一个客户的信息能在单元内闭环。
  • 相对均匀:客户数量大,哈希后各单元负载接近。

按卡号或账户号分片会把同一个客户拆到多个单元,「查客户全部信息」就要跨单元;按地区分片则会在客户迁移时触发数据搬迁。

硬约束是:同一个客户的数据必须落在同一个单元。如果做不到,单元化模型就破了。

路由:从域名到单元 ​

典型链路是 F5/GSLB -> SLB -> 路由层 -> 单元:

  • F5/GSLB:跨机房流量分发、SSL 卸载。
  • SLB:机房内负载均衡和健康检查。
  • 路由层:Nginx + Lua,做分片计算和单元定位。用 Nginx 而不是 Java 服务,是因为事件驱动模型能扛高 QPS,Lua 在 worker 内执行,没有额外网络跳转。
  • 映射服务:维护 CID -> 单元 以及 卡号/账户号 -> CID 的映射;路由层本地有映射文件缓存,未命中才查映射服务。

路由模式有三种:调用方已知 CID 时直接算哈希;标准模式由路由层算;调用方只带 Ticket 时,路由层先调映射服务换 CID,再算分片。

延迟指标要分层说,避免互相矛盾:

  • 路由层本地映射命中:<1ms。
  • 未命中查映射服务 Redis:<2ms。
  • Redis 也未命中查 MySQL 主键:<5ms。
  • 整个路由链路 P99:<15ms(示例目标,按实际压测校准)。

命中率也按链路定义:路由层本地缓存覆盖约 95% 的热点 CID,剩余 5% 查映射服务;映射服务 Redis 再挡住其中的大多数,最终只有约 1% 的总请求落到 MySQL。不要把「95%」和「99%」混着用。

跨单元事务:本地事务 + 消息 + 对账 ​

A 在单元-100、B 在单元-103,转账不能用一个本地事务完成。常见做法:

  1. 单元-100 在本地事务里扣 A 的钱,并写 outbox 消息。
  2. 投递 MQ,单元-103 消费后给 B 入账,消费端按业务 ID 幂等。
  3. T+1 对账两个单元的流水总额,差异告警并人工调账。

这不是强一致:B 可能晚几秒看到钱。换来的是不因为跨单元事务锁住整条链路。具体消息表、幂等和补偿见 02-分布式事务 与 10-幂等性设计。

数据同步与扩容迁移 ​

每个单元的数据库独立做主从同步,同步粒度是单元级,不是全库级。新增单元时用四阶段迁移:

  1. 全量同步:旧单元的数据库 dump 到新单元。
  2. 增量同步:用 binlog 追平全量期间的写入。
  3. 双写验证:映射服务仍指向旧单元,但新写入同时写新旧两边;读仍走旧单元,逐条比对结果。
  4. 切换:映射关系改到新单元,流量切过去;旧单元保留一段时间用于回滚。

回滚就是把映射关系改回去。双写期间要有写冲突检测,不能只比较「写成功」。

容灾切换:按故障半径分级 ​

级别故障处置目标恢复时间
实例级单个 App 实例挂SLB 摘除,流量转同单元其他实例秒级
单元级整个单元不可用路由层切到备机房同名单元分钟级
组件级路由层或映射服务集群挂切备用集群;路由层全挂时退化为调用方直连分钟级
机房级整个机房不可达GSLB 切 DNS,流量转到备用机房10–30 分钟

机房切换前必须处理数据同步:如果备库 binlog 还差 0.3 秒,直接升主会丢最后一段写入。正确顺序是「停止旧主写入 → 等 binlog 追平 → 备库升主 → 切流量」,这段时间该单元不可写,返回明确的繁忙提示。

DNS 切换还有一个现实问题:客户端缓存了旧 IP。GSLB 的 TTL 要短(如 30 秒),客户端 SDK 要能在连续超时后重新解析;否则切换完成了,部分用户还在打旧机房。

实战场景 ​

一、按卡号分片,一次“查客户全部信息”跨了三个单元 ​

现象:新建的手机银行单元化用卡号做分片键(直觉上“交易都带卡号”)。上线后两类问题集中出现:客服查一个客户的全部账户要并行查三个单元再合并,P99 2.4 秒;更严重的是同一客户的两笔关联交易落在不同单元,跨单元一致性只能靠异步对账,日终差异日均 200 余笔(经验值,需校准)。

排查:单元不是数据分片,而是应用与数据的共同边界。

定位层取证据判定
分片键稳定性客户号 vs 卡号/账户号:哪个会新增、变更一个客户对应多卡,卡号必然跨单元
业务聚合性主要查询是否都能用同一键闭环客户维度查询无法闭环即模型破坏
均匀性哈希后各单元负载与数据量分布大客户/机构账户会造成倾斜
硬约束是否存在“同一客户的数据落在不同单元”有即单元化模型不成立
迁移触发换卡、销卡、客户迁移是否引发数据搬迁按地区分片同样有这问题
路由映射卡号/账户号 → CID 映射是否完整可查缺映射就只能广播
选客户号(CID)的三个理由:
稳定(不随卡、账户、手机号变化)、聚合(账户/卡/交易都归属客户)、相对均匀(客户数量大,哈希后负载接近)

对症做法:

text
1. 分片键换成 CID:路由层先由请求里的卡号查映射得到 CID,再按 CID 定位单元;
   映射服务维护 CID → 单元、卡号/账户号 → CID 两级,路由层本地缓存映射文件,未命中才查映射服务。
2. 单元内包含应用实例 + 独立数据库 + 缓存 + 局部路由信息,
   缺任何一项都不是完整单元(只有应用分片、数据共享,等于没做)。
3. 确实需要跨单元的查询(总行级报表)走“离线汇总”或独立查询集群,
   不要在在线交易路径上做跨单元 join。
4. 卡号维度的高频查询靠映射预热:
   登录时把该客户的全部卡 → CID 映射下发到客户端缓存,减少未命中回源。
nginx
# 路由层(Nginx + Lua)示意:worker 内完成分片计算,无额外网络跳转
set_by_lua_block $unit {
    local cid = mapping.cid_for_card(ngx.var.card_no)     # 本地映射文件命中
    if not cid then return ngxHTTP_400("card not mapped") end
    return router.unit_of(cid)                             # CID -> 单元
}

复盘要点:判据是“同一客户的所有读写能在一跳内落到同一单元”,用抽样回放验证:随机取 100 个客户的完整查询链路,跨单元调用次数应为 0。分片键选错后返工的成本是全链路改造(含数据搬迁与路由映射重建),所以它必须在设计评审时用“会不会变、能不能聚合、均不均匀、稳不稳定”四条逐一回答。

二、映射缓存过期不一致,请求进了错误的单元 ​

现象:一次“客户从单元 A 迁移到单元 B”的分流操作后,部分请求仍被路由到 A:这些实例的路由层本地映射文件还是旧值。结果同一个客户在两个单元都有写入,切换完成后出现 14 笔重复记账与 6 笔丢失(经验值,需校准),花两天做数据回迁与对账。

排查:路由是缓存,缓存有生效时点;迁移是跨系统动作,有窗口。

定位层取证据判定
映射版本各路由节点的映射文件版本号与加载时间版本不一致即存在旧路由
迁移阶段数据搬迁完成时间 vs 路由切换时间顺序颠倒会出现双写
写入来源按单元统计同一 CID 的写请求时间线两个单元同时有写即双写发生
未命中处理映射未命中是查映射服务还是广播/默认单元默认单元策略最危险
缓存失效通知是否有主动失效 + 拉取确认机制只靠 TTL 必然有窗口
客户端粘性App/网关是否缓存了单元地址客户端侧也有旧路由

对症做法:

text
1. 迁移必须按“先冻结源单元写入 → 搬迁 → 校验 → 切路由 → 观察 → 解冻”的顺序,
   任何跳步都会双写。源单元在切换前就要拒绝该 CID 的写(返回“处理中,请稍后”)。
2. 路由层用版本化映射:
   每个请求带映射版本号打点,控制面能看到“还有多少节点在用旧版本”,
   未全部收敛前不允许宣布切换完成。
3. 单元侧要有兜底拒绝:
   单元校验“本单元应当负责哪些 CID”,不属于本单元的写入直接拒绝并告警,
   这是防错路由的最后一道闸门。
4. 未命中策略只能是“查询映射服务”,
   绝不能是“默认单元”或“广播所有单元”——两者都会造成跨单元写。
java
// 单元自校验:写路径必须验证归属,不依赖路由层正确
public void handle(TradeCommand cmd) {
    if (!unitOwnership.belongsLocal(cmd.cid())) {
        metrics.misrouted(cmd.cid(), currentUnit);
        throw new NotOwnedByThisUnitException(cmd.cid());      // 明确失败,不落地写入
    }
    ...
}

复盘要点:判据是“misrouted 拒绝计数为 0”与“同一 CID 在 24 小时内被两个单元写入的次数为 0”。分流演练要覆盖“路由节点映射未收敛”这一具体场景,并验证单元闸门能挡住错误写入——路由正确性靠对账收敛太慢,靠写入端拒绝才即时。

三、单元故障时跨单元兜底,把故障扩散成全站 ​

现象:单元 A 的数据库主从切换期间不可写(约 90 秒)。平台配置的“单元不可用则兜底路由到相邻单元”生效,A 的流量全打向 B;B 的容量没有为这部分流量预留,连接池耗尽,随后 B 也触发同样的兜底策略打向 C——三个单元在 4 分钟里依次雪崩。

排查:无上限的兜底等于把局部故障复制成全局故障。

定位层取证据判定
兜底比例相邻单元接收到的跨单元流量占比曲线短时间内跳升即兜底生效
目标单元余量B/C 的连接池、CPU、线程池水位无预留则必然被拖垮
是否有次数/深度限制兜底是否允许级联(A→B→C)级联是本次扩散的直接机制
数据归属校验兜底后写入是否被 B 单元的归属校验拒绝拒绝率高说明兜底本来就写不进去
熔断存在性单元级熔断是否在失败率超阈值时切断没有熔断就只会在兜底里挣扎
业务语义该业务能否接受“暂时失败”决定兜底还是快速失败

对症做法:

text
1. 兜底要有三重限制:
   - 深度:只允许一跳,禁止 A→B→C 级联;
   - 比例:最多承接源单元流量的 X%(按目标单元预留容量算,例如 30%,经验值需校准);
   - 时限:兜底窗口到期(例如 5 分钟)后不再尝试,返回明确失败并转人工。
2. 常态预留:每个单元按“自身峰值 + 相邻单元的 X%”配置容量,
   单元化的高可用不是靠兜底策略,而是靠预留与隔离。
3. 单元内故障用“排队/降级/只读”消化:
   主从切换 90 秒期间,可接受读走从库、写返回“处理中,请稍后重试”,
   不必强行跨单元写。
4. 归属校验优先于兜底:数据不属于本单元就不该写进去,
   跨单元写会直接破坏单元模型的硬约束。
java
// 一跳兜底 + 比例 + 时限
public Unit fallback(Unit src, Duration window) {
    if (fallbackInFlight.get() > MAX_FALLBACK_RATIO * src.capacity()) return Unit.none();
    if (System.currentTimeMillis() - src.failureStart() > window.toMillis()) return Unit.none();
    return neighborOf(src).filter(u -> !u.isFallbackTargetAlreadyUsed()).findFirst().orElse(Unit.none());
}

复盘要点:判据是“单单元故障时,其他单元的负载增幅不超过其预留容量”——这条只能靠演练得到:逐单元杀数据库、断网络,测“是否只影响本单元”。单元级故障只影响该单元正是选它的理由,兜底策略如果不设上限,就等于把这条优势作废。跨单元一致性与数据归属设计见 S02 DDD 与微服务。

最小上手 ​

映射表与查询:

sql
CREATE TABLE cid_unit (
  cid     BIGINT PRIMARY KEY,
  unit_id INT NOT NULL,
  KEY idx_unit (unit_id)
);
SELECT unit_id FROM cid_unit WHERE cid = 123456;

路由层的分片计算可以先用一条命令验证:

bash
# 假设 60 个单元,验证同一个 CID 总是落到同一个单元
for cid in 10001 10002 10003; do
  echo "$cid -> $((cid % 60))"
done

生产环境通常用哈希而不是简单取模;取模只用于说明「同一输入得到同一输出」。

生产动作 ​

  • 映射服务容量:CID -> 单元 按主键查,单表亿级时 B+Tree 精确查找通常 3 次 IO;Redis 缓存热点,MySQL 只做冷数据兜底和全量同步。
  • 热单元:哈希均匀不代表业务量均匀。VIP 客户集中会让某个单元 CPU、连接数远高于其他单元;按 CID 范围拆分或把 VIP 迁到新单元,并监控单单元负载。
  • 跨单元比例:跨单元查询和调用要控制在低位(示例目标 <5%),大部分请求应在单元内闭环;全局统计走离线数仓。
  • 扩容:新增单元要自动化「创建 → 注册 → 迁移 → 切流 → 验证 → 退役」,60 个单元可以人工,500 个单元必须靠控制平面。
  • 切换演练:按季度演练单单元、单机房故障,记录切换耗时、binlog 追平时间和数据差异;切换能力是练出来的。
  • 监控:每单元的 QPS/CPU/连接数、路由层路由延迟、映射服务命中率、跨单元调用比例、对账差异、单元间流量。

边界 ​

  • 单元化适合客户量大、故障隔离要求高、需要线性扩容的场景;小规模系统引入它,只会增加路由和运维成本。
  • 跨单元查询天然昂贵,实时全局聚合通常不划算,应该走离线或预聚合。
  • 分片键一旦选定很难改;客户号变化、并购、账户迁移都要设计迁移路径。
  • 单元之间不追求强一致;跨单元资金流转必须靠消息、幂等和 T+1 对账兜底。
  • 单元化不等于 Kubernetes 的多集群部署;前者是数据和路由模型,后者是运行载体,细节见 K8s 卷。
  • 热单元是哈希分片的固有问题,不能假设「分片数够多就不会倾斜」。

讲清楚 ​

单元化解决的是数据层的扩展和故障隔离。它按客户号把应用、数据库和缓存圈成自包含的单元,一个客户的所有数据在一个单元内,单元内用本地事务,跨单元尽量少调用。路由链是 GSLB、SLB、路由层、单元,路由层用 Nginx + Lua 算分片,映射服务维护客户号到单元的映射,本地缓存挡住大部分查询。扩容时加单元,用全量、增量、双写、切流四阶段迁移;跨单元转账用本地事务加消息加 T+1 对账,不追求强一致。容灾按实例、单元、组件、机房分级,机房切换前必须停写并等 binlog 追平,否则会丢最后一段数据。

把一门技术讲透到既懂也会用