服务边界与数据所有权:共享库、共享表的代价
拆完服务最容易留下的隐患不是接口,而是数据库:进程分开了,表还在一起。共享库让「独立部署」变成一句空话——改一个字段要三个服务同步发版,一条慢查询锁住别人的写入。数据所有权(Data Ownership)是服务边界里唯一不可谈判的部分。
三条规则
规则一:谁能写 —— 一张表只有一个写者。 每个数据集合(表、集合、主题)有且只有一个服务拥有写权限。其他服务即使拿到了数据库账号,也只能读,绝不能写。违反它的后果是事实来源分裂:两个服务各自维护「订单状态」,出问题时谁也说不清哪个是对的。
规则二:谁能读 —— 读取也要走拥有者的接口。
跨服务读取只有三条合法路径(见后文),直接 SELECT 别人的表不在其中。只读副本与数据仓库是例外:它们服务于分析与报表,不参与在线业务判断。
规则三:跨服务怎么拿数据 —— 声明一致性预期。 取数方式决定了你能容忍多旧的数据。把「允许旧多少」写进契约,而不是让调用方自己祈祷。事实来源(Source of Truth)只有一个,其余都是派生数据(Derived Data)。
共享库与共享表的典型事故
| 事故 | 触发方式 | 影响 |
|---|---|---|
| 加字段引发写入失败 | A 服务给表加了 NOT NULL 且无默认值的列 | B 服务的 INSERT 全部报错 |
| DDL 锁表连锁 | 大表加索引、改列类型 | 所有服务一起超时 |
| 长事务锁行 | A 服务在共享表上开长事务 | B 服务写同一批行时排队、超时 |
| 越权删除 | 各服务共用同一个高权限账号 | 一次误操作删掉别人的数据 |
| 连接数打满 | 所有服务连同一个库 | 一个服务流量翻倍,全库连接耗尽 |
| 无法独立扩容 | 分库分表只能整体做 | 拆分的收益归零,等于回到单体 |
共享表最隐蔽的代价是 schema 变成了事实上的公共接口:任何改动都要跨团队协商,于是没人敢加索引、没人敢删字段,表越长越胖,最后靠一堆 status2、ext_json 兜着业务。
跨服务查询的三种做法
| 做法 | 时效 | 耦合度 | 主要代价 | 适用 |
|---|---|---|---|---|
| API 组合(API Composition) | 实时 | 高(同步依赖下游) | 延迟叠加、N+1 调用、下游故障直接可见 | 结果必须立即准确、调用量可控 |
| 冗余读模型(Materialized Read Model) | 秒级最终一致 | 低(只依赖事件) | 写放大、存储冗余、要处理陈旧数据 | 高频列表页、详情页、跨服务展示 |
| 数据同步(CDC / ETL) | 分钟到小时 | 最低 | 延迟大、口径易漂移、不适合在线判断 | 报表、搜索、离线特征 |
关键是别混用:一个页面里一半字段走实时接口、一半走冗余副本,用户会看到自己刚改的数据「一半更新了、一半没更新」,这种 bug 极难复现。
做法一:API 组合
由调用方(或专门的组合服务)并发调用多个服务再拼装。真实代价不在代码,在可用性:页面可用性等于所有依赖服务可用性的乘积,5 个 99.9% 的服务串成一次请求,上限就掉到约 99.5%。所以 API 组合只适合调用深度不超过 2 层、且必须有超时与降级的场景。
做法二:冗余读模型
订阅事件(或变更流),在本地维护一张只读表。查询变成单库单表,性能和可用性都上来了,代价是最终一致:写入方提交到副本可见之间存在时间窗,产品上必须能接受(用户可见后果见第 12 章)。
-- 订单服务本地维护的「用户摘要」读模型:只读,由 UserUpdated 事件刷新
CREATE TABLE user_summary (
user_id BIGINT PRIMARY KEY, -- 与用户服务的标识一致,不另生成 ID
nickname VARCHAR(64) NOT NULL,
avatar VARCHAR(255) NOT NULL DEFAULT '',
version BIGINT NOT NULL, -- 事件里的版本号,用于丢弃乱序的旧事件
updated_at DATETIME NOT NULL
);
-- 幂等且防乱序的落库写法:只接受版本更大的事件
INSERT INTO user_summary (user_id, nickname, avatar, version, updated_at)
VALUES (?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
nickname = IF(VALUES(version) > version, VALUES(nickname), nickname),
avatar = IF(VALUES(version) > version, VALUES(avatar), avatar),
version = GREATEST(version, VALUES(version)),
updated_at = IF(VALUES(version) > version, NOW(), updated_at);
做法三:数据同步
用变更数据捕获(Change Data Capture, CDC)或定时 ETL 把数据搬到搜索库、数仓、特征库。它解决的是「不该由在线服务承担的查询」,代价是链路长、口径容易漂移,必须配一个对账任务定期比对源与副本的条数和关键字段。
落地做法
- 一个服务一个库或一个 schema,独立账号,只对拥有者的表授权写入:
-- 每个服务一个账号,只给自己库的权限;跨库万能账号一律不建
CREATE DATABASE order_db DEFAULT CHARACTER SET utf8mb4;
CREATE USER 'svc_order'@'%' IDENTIFIED BY '<从密钥管理注入>';
GRANT SELECT, INSERT, UPDATE, DELETE ON order_db.* TO 'svc_order'@'%';
-- 明确不授予 DROP / ALTER:DDL 走发布流程,由迁移工具或 DBA 执行
- 用标识引用,不用跨库外键:表里存
user_id,不建指向别人库的外键,更不做跨库 join; - 变更靠事件发出:本地事务提交时把事件写进 outbox 表,由中继投递(第 14 章),而不是「先写库再调接口」的双写;
- 一致性预期写进契约:接口文档注明「昵称最长滞后 5 秒」,让调用方自己决定要不要实时查;
- 必须有对账:任何派生数据都要有周期性校验与自动修复入口。
失败模式
- 双写不一致:写库成功、发消息失败(或反过来),两边永久分叉。修法是 outbox 或事务消息,不是加大重试次数;
- 事件丢失无人知:副本表悄悄缺行,监控只看消费延迟不看条数差异,几个月后才发现;
- 把读模型当权威:有人为了省事往副表写数据,两个事实来源立刻出现;
- 共享账号:为了省事发一个万能 DBA 账号,权限边界等于不存在;
- 实时读与异步读混用:同一个页面两种时效,用户看到「改了一半」。
小结:数据所有权是服务边界的地基,三条规则记牢——一张表一个写者、读取也走拥有者接口、跨服务取数必须声明一致性预期。共享库与共享表的代价不在性能,而在于把独立部署、独立扩容、独立演进这三样东西一起还了回去。跨服务查询在 API 组合、冗余读模型、数据同步里三选一,选完就要接受它对应的时效与代价,并把对账补上。