服务边界与数据所有权:共享库、共享表的代价

拆完服务最容易留下的隐患不是接口,而是数据库:进程分开了,表还在一起。共享库让「独立部署」变成一句空话——改一个字段要三个服务同步发版,一条慢查询锁住别人的写入。数据所有权(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 把数据搬到搜索库、数仓、特征库。它解决的是「不该由在线服务承担的查询」,代价是链路长、口径容易漂移,必须配一个对账任务定期比对源与副本的条数和关键字段。

落地做法

  1. 一个服务一个库或一个 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 执行
  1. 用标识引用,不用跨库外键:表里存 user_id,不建指向别人库的外键,更不做跨库 join;
  2. 变更靠事件发出:本地事务提交时把事件写进 outbox 表,由中继投递(第 14 章),而不是「先写库再调接口」的双写;
  3. 一致性预期写进契约:接口文档注明「昵称最长滞后 5 秒」,让调用方自己决定要不要实时查;
  4. 必须有对账:任何派生数据都要有周期性校验与自动修复入口。

失败模式

  • 双写不一致:写库成功、发消息失败(或反过来),两边永久分叉。修法是 outbox 或事务消息,不是加大重试次数;
  • 事件丢失无人知:副本表悄悄缺行,监控只看消费延迟不看条数差异,几个月后才发现;
  • 把读模型当权威:有人为了省事往副表写数据,两个事实来源立刻出现;
  • 共享账号:为了省事发一个万能 DBA 账号,权限边界等于不存在;
  • 实时读与异步读混用:同一个页面两种时效,用户看到「改了一半」。

小结:数据所有权是服务边界的地基,三条规则记牢——一张表一个写者、读取也走拥有者接口、跨服务取数必须声明一致性预期。共享库与共享表的代价不在性能,而在于把独立部署、独立扩容、独立演进这三样东西一起还了回去。跨服务查询在 API 组合、冗余读模型、数据同步里三选一,选完就要接受它对应的时效与代价,并把对账补上。

笔记加载中…