主题
单元化架构
一句话:微服务拆的是代码,单元化拆的是数据——按客户把应用、数据库和路由圈成自包含的单元,用跨单元最终一致换故障隔离和线性扩容。
适用: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,转账不能用一个本地事务完成。常见做法:
- 单元-100 在本地事务里扣 A 的钱,并写 outbox 消息。
- 投递 MQ,单元-103 消费后给 B 入账,消费端按业务 ID 幂等。
- T+1 对账两个单元的流水总额,差异告警并人工调账。
这不是强一致:B 可能晚几秒看到钱。换来的是不因为跨单元事务锁住整条链路。具体消息表、幂等和补偿见 02-分布式事务 与 10-幂等性设计。
数据同步与扩容迁移
每个单元的数据库独立做主从同步,同步粒度是单元级,不是全库级。新增单元时用四阶段迁移:
- 全量同步:旧单元的数据库 dump 到新单元。
- 增量同步:用 binlog 追平全量期间的写入。
- 双写验证:映射服务仍指向旧单元,但新写入同时写新旧两边;读仍走旧单元,逐条比对结果。
- 切换:映射关系改到新单元,流量切过去;旧单元保留一段时间用于回滚。
回滚就是把映射关系改回去。双写期间要有写冲突检测,不能只比较「写成功」。
容灾切换:按故障半径分级
| 级别 | 故障 | 处置 | 目标恢复时间 |
|---|---|---|---|
| 实例级 | 单个 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 追平,否则会丢最后一段数据。