从模块化单体到微服务:契约适配、写权迁移与回切
把商品模块部署成独立服务后,原来的方法调用会变成网络请求,商品数据需要确定由哪个服务修改,订单与商品之间的事务也要重新安排。迁移还会经历一段新旧代码同时运行的时期:旧客户端继续请求,新服务逐步接管,途中已经提交的业务操作必须能够查回。
拆分需要有具体目的,例如独立发布或分配资源。确定这一目的后,接口和数据可以逐步交接;迁移中的每一次写入都要能确定由哪一侧处理,并能查回原操作结果。
为独立部署选择合适的业务单元
从实际约束判断是否拆分
模块化单体已经可以通过公开接口、包检查和模块测试管理代码依赖。独立服务进一步改变部署与运行方式:可以分别发布、分配资源和设置访问权限,同时也增加网络、版本兼容和故障处理工作。
| 当前约束 | 先核对什么 | 可选择的改变 |
|---|---|---|
| 商品导入占满应用内存,订单接口受影响 | 内存主要花在解析、缓存还是共同数据库查询上 | 先限制导入并发;需要独立资源时抽出导入执行进程 |
| 商品与订单的发布频率差别很大 | 是否仍需同时改共享字段、SQL 或业务规则 | 先稳定模块契约;共同修改已减少后独立发布 |
| 某类计算有独立峰值 | 峰值时真正饱和的资源及扩容后的下游压力 | 单独扩缩容计算服务,保留容量与准入限制 |
| 多个团队频繁修改同一批表 | 谁负责每个业务规则,是否存在跨模块写入 | 整理修改入口和数据归属,再决定数据库拆分 |
| 某些客户需要专用资源或网络访问策略 | 数据、凭据、备份和运维权限需要隔离到哪一层 | 专属数据库或部署池;应用未必需要拆成更多服务 |
例如,瓶颈来自共同数据库上的长事务时,把两个模块改成两个容器,事务仍然会争用同一批行锁。进程拆分可以限制某个 JVM 的内存影响,数据库、消息服务或宿主机的共同故障仍会同时影响两边。
选择服务粒度时,关注同一次业务修改会触及哪些规则和数据。商品名称、上下架状态和价格可以共同组成商品服务;按 Controller、Service、Repository 分别部署,则会让一个商品更新在几个网络节点间往返。已有模块的整理方法见模块化单体。
连同数据修改入口一起拆分
服务负责的数据,需要覆盖正常 API 之外的修改途径。商品价格可能同时被管理后台、定时促销、批量导入、消息消费者和人工 SQL 更新。迁移只改 HTTP 路由,会遗漏后面几条路径。
商品修改入口
├─ 管理后台:修改价格、上下架
├─ 批量导入:校验后更新商品
├─ 定时任务:生效或结束促销
├─ 消息消费者:接受已授权的业务命令
└─ 运维修复:受限账号、审批与操作记录
↓
同一套商品校验和提交规则读取也有不同需要。订单创建可以通过商品 API 获得当前报价,并在订单中保存当时的商品名称和成交金额。报表需要的大批量历史数据,更适合单独的数据接口或查询副本,避免每行都远程调用一次商品服务。共享数据库中的只读兼容视图可以作为过渡,但直接依赖新服务的内部表结构会继续限制它的独立变更。
数据库实例、数据库、Schema 和表的归属可以分步调整。不同数据库共用一个实例仍共享进程与存储资源;独立数据库账号可以先收紧访问权限,但无法提供独立宿主级故障隔离。
重新安排跨服务业务事务
原来同一数据源上的订单插入与库存预留,可以由本地事务共同提交。改成订单服务调用库存服务后,订单连接的 @Transactional 管不到远程数据库。把 HTTP 调用放在事务方法里,还会延长本地锁与连接占用。
需要先划清业务允许出现的中间状态。例如订单先进入“待确认”,库存服务按订单操作身份预留库存;预留成功后订单确认,失败则取消。释放预留是一笔新的业务操作,它要处理重复、超时和预留已经使用等情况。已经发送的货物或已完成的外部付款,也有各自的撤销规则。Saga 的协调和补偿方式可查 AWS Saga orchestration。
如果一项规则要求两个数据对象必须在同一提交点完成,且业务暂时不能接受中间状态,可以继续把这部分数据与修改逻辑放在同一个服务。异步通知、统计和搜索更新则可以从主事务中分离,具体投递与副本恢复见数据演进。
保留公开契约,让新旧服务同时运行
用适配层逐步接管入口
Strangler Fig 迁移保留原来的对外入口,把选中的功能逐步路由到新实现,其余请求仍由旧系统处理。适用于可以识别和分离请求的系统;入口本身的容量、可用性与绕过路径需要一起考虑。AWS 的模式说明给出了渐进接管的适用条件。
新旧模型不同的部分可以集中在适配器中转换,避免让旧字段约定渗透到新服务各处。反腐层可以位于调用方、网关旁的应用组件或独立服务,位置取决于共享范围和运维成本,见 Microsoft Anti-Corruption Layer。
下面的实验保留公开商品接口,同时运行不同的内部读取格式:
| 公开商品字段 | 旧服务 /products/{id} | 新服务 /v2/products/{id} |
|---|---|---|
商品标识 id | id | productId |
说明 description | description | label |
价格 priceCents | 整数分 | money.minor,并检查 currency=CNY |
版本 version | version | revision |
网关检查新响应的结构、字段类型和币种后再转换。缺少版本、金额变成字符串或返回损坏 JSON 时,应暴露上游契约问题,不能给调用方补一个零价格。实验把损坏上游响应归入 502;客户端发送非法 JSON 则为 400,两种来源分别处理。
已有客户端还会依赖错误码、必填字段、金额单位、授权规则、排序分页和幂等行为。这些约定与成功响应的字段名称一起构成兼容性检查的范围。
启动两个数据库、两个服务和一个网关
下载并解压服务迁移与 HTTP 灰度实验工程,进入 evolution-migration-lab。使用 Linux Bash、Docker Engine、Compose v2、openssl 和支持 --fail-with-body 的 curl;宿主用户需要 Docker 权限和该目录的写权限。
工程使用 PostgreSQL 18.6、JDBC 42.7.13、Jackson 2.18.3,编译目标 Java 17。默认运行镜像为 Temurin 17.0.20_8-jdk,也可用 25.0.4_7-jdk 对照。Maven 3.9.12 镜像用于构建,实际内置 JDK 需通过 java -version 确认,与应用运行镜像分开记录。
以下命令在同一 Bash 会话、同一解压目录中顺序执行。set -euo pipefail 使非预期失败停止后续步骤;负例另外检查具体失败原因。
set -euo pipefail
export LAB_PROJECT=evo25migration
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" --entrypoint mvn \
-e MAVEN_CONFIG=/m2 -v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
-B -Dmaven.repo.local=/m2 clean verify
bash setup.sh migration
bash up.sh
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--connect-timeout 3 --max-time 15 \
-D work/first.headers http://127.0.0.1:18880/products/p1
bash run.sh inspect构建应完成七项输入、路由、语义比较与回滚异常测试,结尾为 BUILD SUCCESS。随后的数据库与 HTTP 实验单独执行。首次 GET 返回 p1、priceCents=1000、version=1,响应头 X-Lab-Backend 为 legacy。检查结果应是旧库三个商品且允许写入,新库为空且禁止写入。
127.0.0.1:18880 网关:公开 /products 路径
├─ legacy:18081 → evo_legacy 数据库
└─ next:18082 → evo_next 数据库
诊断入口:127.0.0.1:18881 / 18882
数据库: 127.0.0.1:18832两个数据库位于同一 PostgreSQL 实例,仅用于节省实验资源。数据库容器上限 512 MiB,三个 Java 服务各 192 MiB;应用以宿主 UID/GID 运行,程序和服务配置只读挂载。PostgreSQL 镜像由入口脚本初始化,数据库进程使用 postgres 身份。管理工具以数据库 postgres 管理账号执行建库和交接,并可写 work 配置;普通服务使用各自受限的应用账号。
setup.sh 生成各不相同的随机数据库口令,写入私有 work/runtime.env。该文件不应提交、贴到日志或截图;文件已经存在时,初始化拒绝覆盖,避免用新口令或新夹具破坏未完成迁移。
如果端口占用,先识别占用服务,再统一设置 PG_PORT、OLD_PORT、NEXT_PORT、GATEWAY_PORT。不同 Compose 项目同时运行时,还需要独立解压目录和端口。依赖下载失败可使用自己的 Maven 私服 settings.xml 与 -s 参数;恢复依赖后重新构建,确认实际执行了七项测试。
这些回环入口没有实现生产用户认证、TLS 或公网网关保护。生产部署需校验调用方和目标资源权限,限制服务间网络访问,并把临时直连诊断入口纳入访问控制。
读路由、影子流量与新鲜度
读取迁移可以先在后台复制一部分无副作用请求,将旧响应交给用户,新响应只用于比较。影子读取也会消耗数据库和网络资源;若接口会刷新访问状态、触发导出或发送通知,必须先排除这些副作用。
进一步的读灰度应保持同一客户或业务键稳定分组。实验网关对 X-Lab-Cohort 做 SHA-256 分桶,候选比例改变后,同一个 key 的选择可复查。这个头仅作本地实验分组,不能作为授权身份或允许外部用户自行决定敏感发布资格。
读流量进入新库前,需要先准备相应数据并确定可接受的新鲜度。当前 migration 初始化的新库是空的,不能直接把候选比例调到 20% 当成正常灰度。工程另有两边数据一致、两边均禁写的独立 canary 初始化;完整的对照采样和故障回退在架构评审展开,使用新的解压目录,不与本次写迁移混跑。
对持续更新的业务,旧新响应可能来自不同版本。比较时应关联实体与版本,区分复制延迟和业务算法差异;金额、权限、商品是否存在等业务差异要保留。只看两侧 HTTP 成功率,无法发现一个返回 200 的错误价格。
远程调用的等待和重试
每个同步调用都会占用等待资源。实验设置连接超时 3 秒、网关上游请求超时 10 秒;JDK 请求超时的精确行为见 HttpRequest.Builder。这组数值用于本地故障控制,尚未实现多跳剩余时限传播。
生产链路可以给一次业务操作分配总时间预算,让下游只使用剩余预算。重试还应有可重试条件、总次数、退避和抖动,避免每一层各自重试。超时后原服务可能仍在执行,取消等待也不会自动回滚已提交事务。Amazon Builders’ Library 的超时与重试说明详细讨论了这些取舍。
冻结写入口,复制业务数据与原操作结果
把禁写检查放进真实提交路径
实验选择短暂停止写入,再复制完整数据。停写需要覆盖已经进入事务的请求和绕过网关的调用,因此写控制位于数据库函数内。
应用账号
├─ 可以 SELECT 商品
└─ 可以执行 lab.set_price(...)
↓ 同一事务
锁定 write_control 行并检查 enabled
↓
├─ 同 operationId:核对请求摘要,返回原结果
└─ 新 operationId:核对商品版本,修改价格
↓
保存原响应,与商品更新一起提交应用没有商品、写控制和操作表的直接 DML 权限。SECURITY DEFINER 函数由不能登录的 owner 持有,固定可信 search_path,并撤销 PUBLIC EXECUTE。普通应用只能通过受限函数修改价格,不能自行把控制行改为可写。函数执行身份与路径安全要求见 PostgreSQL CREATE FUNCTION。
函数开头使用 SELECT ... FOR UPDATE 锁定唯一控制行,锁一直持有到事务结束。管理停写更新同一行,会等待在途写事务完成,然后持久保存 enabled=false。之后进入函数的请求看到禁写状态,返回 503 WRITE_DISABLED。行锁和等待规则见 PostgreSQL Explicit Locking。
这条控制行同时串行化了所有商品更新,适合观察交接时序,不适合衡量业务吞吐。真实系统可以采用符合其数据库和存储接口的写权限交接机制,但不能漏掉后台任务、直接 SQL、长事务和旧进程继续提交的可能。
执行两个实际检查:
bash run.sh probe permissions
bash run.sh probe drain权限检查分别使用旧、新应用身份尝试修改商品、控制行和操作表,要求 SQLSTATE 42501,随后核对数据未变。排空检查让一个商品事务暂不提交,用 pg_blocking_pids 观察管理停写确实被该事务阻塞;原事务提交后,停写完成,下一次 HTTP 写入被拒绝。检查结束恢复已知的旧写者。
此时 p1 已从 1000/v1 更新为 1100/v2,旧库有一条操作记录。继续发一次公开 HTTP 更新:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--connect-timeout 3 --max-time 15 \
-H 'Content-Type: application/json' \
--data-binary '{"operationId":"00000000-0000-4000-8000-000000000002","expectedVersion":2,"priceCents":1200}' \
http://127.0.0.1:18880/products/p1/price
bash run.sh probe before返回 1200/v3。probe before 重放相同操作并保存原业务响应,再用同一 ID 提交不同金额,要求 409 OPERATION_CONFLICT。
幂等记录为什么也要迁移
两张表保存的内容不同:
product
id / description / price_cents / version
保存商品当前状态
operation
operation_id / request_hash / status / response_body
保存某次已提交操作的请求身份与原 HTTP 业务结果请求摘要由规范化后的操作类型、商品 ID、期望版本和金额计算,JSON 空白差异不会改变同一请求。幂等 ID 对应相同内容时返回已保存结果,内容不同则拒绝;expectedVersion 用来阻止基于旧商品状态的新修改。两者分别解决重复执行和并发修改问题。
假如某次更新原本得到 1200/v3,商品后来已变为 1500/v5,重试原操作仍应取回原来的 1200/v3。只复制商品表,新服务就可能把旧重试看成新操作,或只能返回版本冲突,丢失原来承诺的重试结果。
实验将操作响应体原样保存并复制,迁移后比较业务响应字节。X-Lab-Backend 会随路由变化,不纳入这份稳定业务结果。实际系统还要定义幂等身份的租户和操作范围、保存时间、敏感字段处理以及超期重试行为。
迁移中每一步允许谁写
管理工具在旧库保存迁移阶段,在两库分别保存实际写控制状态。整个迁移由一个管理命令推进,每个阶段都能通过 inspect 查看:
| 阶段 | 旧侧 | 新侧 | 下一步依赖 |
|---|---|---|---|
初始 FINISHED,owner=legacy | 可写 | 禁写、空库 | 确认当前只有旧写者 |
FREEZING | 停写并等在途事务 | 确认禁写 | 两边都已冻结 |
COPYING | 保留源内容、禁写 | 在一个事务中复制 | 商品与原操作记录完整相等 |
VERIFIED | 禁写 | 启写前再次核对;也可能已启写但尚未登记下一阶段 | 读取实际控制状态,避免覆盖新写入 |
ACTIVATED | 禁写 | 可写 | 更新路由并登记新 owner |
最终 FINISHED,owner=next | 禁写、保留旧数据 | 可写 | 观察新调用与回切准备情况 |
复制仅处理专属的 product 与 operation,不把管理记录或控制位从源库覆盖到目标。目标替换在一个数据库事务中完成,异常时回滚该事务,源库保持不动。
复制中断后从已确认状态继续
先在“商品已写入目标、操作记录尚未复制”处制造异常:
if bash run.sh migrate next --fail-copy >work/copy-failure.log 2>&1; then
printf '预期复制失败没有发生\n' >&2; exit 1
fi
grep -q INJECTED_COPY_BEFORE_OPERATIONS work/copy-failure.log || exit 1
bash run.sh probe copy-rollback应看到目标事务回滚为空,两边均禁写,旧库仍保留三个商品和两条操作。若只是网络连接失败,日志不会包含指定注入点,检查会停止,不把它当作预期复制回滚。
再运行一次,让复制提交成功后管理进程退出:
if bash run.sh migrate next --crash-after-copy >work/copy-interruption.log 2>&1; then
printf '预期复制后中断没有发生\n' >&2; exit 1
fi
grep -q INJECTED_AFTER_COPY_COMMIT work/copy-interruption.log || exit 1
bash run.sh probe copy-committed目标内容已经完整,管理记录仍在 COPYING。新的 CLI 进程检查两边内容和禁写状态;在两边仍冻结时,可以重新执行目标复制。这里没有依靠上一个 JVM 的内存保存进度。
另一个需要单独处理的窗口,是新库已经允许写入,而管理记录仍为 VERIFIED:
if bash run.sh migrate next --crash-after-enable >work/enable-interruption.log 2>&1; then
printf '预期启写后中断没有发生\n' >&2; exit 1
fi
grep -q INJECTED_AFTER_TARGET_ENABLED work/enable-interruption.log || exit 1
bash run.sh probe activated-window
bash run.sh migrate next
bash run.sh probe after-forward在这个窗口,网关还指向已禁写的旧服务,写请求返回 503。activated-window 直接向已启写的新服务提交 p2=2200/v2。后来的管理进程读到新侧已可写,继续完成路由和阶段登记,不能再把旧快照复制回来覆盖 p2。
after-forward 确认 p2 新值保留、迁移前的原操作响应可精确重放、旧服务直写被拒绝,并验证非法尾随 JSON 不修改数据。随后的合法 p1 更新得到 1400/v4。
这些注入点抛出明确异常并结束 CLI,然后由新进程继续,不是 SIGKILL、宿主机断电或跨机失联实验。路由文件采用同目录临时文件原子替换,尚未验证断电后的 fsync 持久性。
管理互斥使用旧库会话级 advisory lock。它约束配合协议的管理命令,不是目标存储验证的 fencing token。如果持锁连接丢失,先确认原管理进程确已停止,再接管;不能据此允许网络分区中的两个管理进程同时迁移。需要自动高可用交接时,应另行实现并测试失去管理资格后旧进程的所有写控制操作均被拒绝。
处理未知提交结果,并准备真正可用的回切
新服务提交后,网关仍可能返回错误
bash run.sh probe unknown该检查使新服务提交价格更新和操作记录,然后在发送响应前关闭连接。网关实际返回 502 UPSTREAM_TRANSPORT,不会把写请求改投旧服务。同一 operationId 再次请求新侧,取回已经保存的 1500/v5。
新库提交:商品 p1=1500/v5 + 原操作响应
↓
新服务关闭响应连接
↓
网关返回 502,调用方尚未得到业务结果
↓ 使用同一操作身份重试
新服务返回已保存的 1500/v5,不再修改商品检查同时读取两库操作表:新库五条,旧库仍两条。新库的五条包含 p2 在启写窗口中的更新;并非 p1 被重复执行了五次。
调用方遇到写结果未知,应保留原操作身份,按接口约定查询状态或重试。重新生成一个 ID 会失去服务端关联;自动改投尚未同步的新旧另一端,则可能重复副作用或制造分叉。若业务没有可靠的幂等查询入口,需要先核对真实提交记录,再决定补发。
先展示只切读路由的后果
bash run.sh probe stale-return它暂时把读路由切回旧侧,实际读到 p1=1200/v3,而新侧已为 1500/v5。随后尝试让旧服务成为写目标,因 UNVERIFIED_WRITE_OWNER 被拒绝;检查结束恢复新侧读取。
旧服务仍能回答请求,却只能读到接管前的数据。保留程序和数据库减少了重新部署的工作;回切前还缺接管期间的更新。
回迁接管期间的数据,再恢复旧写者
bash run.sh migrate legacy
bash run.sh probe after-return
bash run.sh inspect回切按相反方向执行:冻结新写并等在途事务完成,确认旧库禁写,把新库商品与全部原操作响应复制回旧库,逐项核对后启旧并切入口。
最终结果应满足:
| 对象 | 旧侧 legacy | 新侧 next |
|---|---|---|
| 写控制 | 可写 | 禁写 |
| p1 | 回切后的新操作得到 1600/v6 | 保留回切前的 1500/v5 |
| p2 | 2200/v2 | 2200/v2 |
| 已保存操作 | 6 条 | 5 条 |
| 断连操作重放 | 返回原来的 1500/v5,业务响应字节不变 | 原响应仍保留 |
新侧直写再次被拒绝。这样既检查历史请求的恢复,也确认接管后的下一条新写可以完成。
同一新环境可选择逐步操作,或者在初始化后直接执行 bash verify-migration.sh 自动完成相同路径;不要先改变夹具后再跑完整脚本。Java 25 对照需要新的解压目录和项目名,使用 Maven 25 镜像重新构建,并设置 LAB_JAVA_IMAGE=eclipse-temurin:25.0.4_7-jdk 后初始化;先停止旧实验,避免争用默认端口。
数据量较大时,在线迁移还要增加什么
短停写方案的暂停时间包含排空、复制、核对与启写。数据量太大或业务允许窗口太短时,可以提前建立目标副本,用快照和增量同步缩小最终交接工作量。
在线路径需要定义快照对应的变更日志位置、持续复制的顺序和断点、切换时的追平条件,以及旧写者停止后的最后一批变化。PostgreSQL 逻辑复制提供发布订阅和初始复制能力,机制入口见官方逻辑复制文档。DDL、序列等对象有独立限制,不能假设复制业务表就完成整个数据库迁移,详见逻辑复制限制。
如果应用向两库分别提交,两次本地提交之间就有一边成功、一边失败的窗口。异步补发可以恢复缺失更新,但需要可追踪的操作身份与顺序。允许两边独立修改同一对象时,还必须处理冲突;单向复制方案无法直接承担这个要求。
Schema 和事件格式也要跨版本兼容。常见做法是先增加旧代码仍能容忍的字段或接口,再更新消费者、回填并核对,最后停止旧读取并移除兼容结构。删除列、改变金额单位、复用枚举值等破坏性操作应等相应消费者退役后再执行。仅保留旧镜像无法恢复它已经无法理解的数据。
短停写实验没有实现上述在线同步工作。数据规模、可接受的停写窗口和运维能力共同决定是否需要它们,小数据复制成功尚不足以判断在线迁移的可行性。
从故障对象找到恢复动作
| 现象 | 先查什么 | 恢复后检查 |
|---|---|---|
| 所有写请求都是 503 | inspect 中的迁移阶段、两侧 enabled、当前写路由 | 根据已确认阶段继续;确认唯一可写侧和下一条合法写入 |
| 停写超过等待时间 | 旧库长事务、控制行等待、实际阻塞连接 | 处理具体事务后重试停写,确认在途事务已结束 |
| 复制失败 | 精确异常点、目标事务结果、源数据是否仍完整 | 两边禁写时重新复制并逐字段核对 |
| 阶段为 VERIFIED 但新侧已可写 | 实际控制行、目标是否已有新操作 | 保留目标更新,继续阶段与路由,不覆盖目标 |
| 同 ID 重试返回冲突 | 请求摘要、商品、金额和期望版本是否变化 | 用原请求查回原响应;真实新业务使用新的操作身份 |
| 网关写入返回 502 | 上游传输、对应操作记录、业务当前版本 | 查回原结果,确认没有改投另一写者 |
| 切旧后价格落后 | 新侧接管期间的商品与操作记录差异 | 停止不完整回切,补齐后核对并验证新写入 |
| 管理工具无法连接旧管理库 | 数据库会话、原 CLI 是否仍运行 | 先停止旧管理者,恢复管理连接后查阶段;不并行手工启写 |
应用日志可关联操作 ID、迁移运行标识、来源版本和错误分类,但不打印口令或完整敏感请求。分布式 Trace 用来串联网关与服务调用,传播方法见 OpenTelemetry Context propagation;业务是否提交仍需结合操作记录核对,Trace 采样可能没有保留某次请求。
结束双轨运行
新路径运行稳定后,检查旧 HTTP 调用、后台任务、数据库连接和旧事件消费者是否仍有使用者。保留恢复窗口内所需数据和配置,再逐项撤销旧写权限、移除兼容适配、停止旧服务。旧表或旧数据库的删除需要单独确认目标和保留要求,不与切流命令绑定执行。
本地实验关闭使用:
bash stop.sh它只移除当前 Compose 项目的容器和网络,保留数据库卷与 work 中的迁移记录。若中途失败,需要恢复这些状态,而不是重新执行 setup.sh。确认无需再查回后,再核对属于本实验的卷并单独处理。
权威资料与规范地址
服务接管、事务交接及跨版本数据同步的具体规则可从下列入口查阅:
