服务边界与拆分:业务职责、数据归属和渐进迁移
商品当前售价是 29.90 元,一笔已经成交的订单仍可能按 19.90 元结算。定价系统管理正在生效的价格,订单系统保存成交时采用的价格。两个系统都出现“商品”和“价格”,需要维护的数据却不同。
服务拆分首先要处理这种业务差异。进程、数据库账号和 HTTP 接口随后把划分落实到运行环境:谁可以修改价格,订单从哪里取得价格,已经成交的数据怎样保存,以及定价服务停止后哪些操作还能继续。
从业务操作划出服务职责
先找必须一起成立的规则
一个电商系统可以按下面的业务操作展开:
商品与交易
├─ 商品目录:维护名称、规格、类目与上下架状态
├─ 定价:计算某种交易条件下适用的价格
├─ 订单:记录购买意图、成交明细及订单状态
├─ 库存:记录可售数量,预留、扣减或释放库存
├─ 支付:创建支付单,确认渠道结果与退款
└─ 履约:分配仓库,发货并记录交付结果这棵树列出业务能力,还没有决定部署多少个服务。判断拆分位置时,需要继续查看业务规则。例如,同一库存账户的可用量不能被并发扣成负数;订单各明细汇总后必须与应付金额一致。这类必须持续成立的条件称为业务不变量。
聚合把需要统一维护的不变量放在一个明确的操作入口后面。修改订单明细时,订单聚合可以同时重算总额;外部调用者不能绕过它,单独修改总额字段。如果每次维护一个不变量都要同步修改三个服务,再用跨服务事务补救,应该先重新检查拆分位置。
不变量的范围也需要业务确认。“下单成功”可能表示已经创建待支付订单,也可能要求库存预留和支付授权都成功。两种含义对应不同的状态、等待时间与失败处理方式,不能仅靠接口名称决定。
同一个名词可以有不同的数据模型
限界上下文是某套业务模型和术语适用的范围。商品目录里的商品包含介绍、规格与图片;订单里的商品明细通常只需保存商品标识、成交名称、数量和金额。让所有服务共享一个不断变大的 Product 实体,会把无关字段及其变更一起传播出去。
| 数据 | 由谁维护 | 其他业务怎样使用 |
|---|---|---|
| 当前商品名称、规格与展示图片 | 商品目录 | 查询接口或订阅目录变化 |
| 当前适用价格、规则版本 | 定价 | 请求报价,得到金额、币种及版本 |
| 订单成交名称、成交单价、数量 | 订单 | 创建时保存快照,后续按订单查询 |
| 库存预留状态 | 库存 | 使用预留标识查询或提交释放操作 |
| 支付渠道结果 | 支付 | 通过支付单状态或事件得知结果 |
快照保存的是接收方业务需要长期解释的事实。订单复制商品详情页的全部图片和营销规则,通常没有必要;如果连成交单价也在查询时临时关联当前价格,历史订单又会随改价而变化。
上下文之间还要说明交互方向。定价提供报价接口,订单消费该接口;外部支付渠道使用自己的状态码,支付服务把它转换成内部支付状态。这种转换层常称为反腐层,用途是阻止外部模型直接扩散到内部业务。领域划分、上下文与集成关系的建模方法可查阅 Microsoft 领域分析指南。
模块、服务和团队怎样对应
这些划分处于不同层次:
业务模型:限界上下文、聚合、不变量
代码结构:模块、包、公开接口、内部实现
运行结构:进程、服务地址、实例、部署单元
数据访问:数据库角色、schema、表、可执行操作
组织安排:维护团队、发布节奏、支持职责一个上下文可以先由一个模块实现,与其他模块运行在同一进程。高负载的计算部分也可能部署成多个工作进程,但仍属于同一个业务上下文。拆分时要逐层决定,不能只把代码目录名称翻译成服务名称。
团队分工可以帮助一个服务保持稳定维护,也可能造成反向牵制:每次发布订单都要等待定价团队改公共包,说明它们仍存在发布耦合。需要检查共享的究竟是稳定的通信格式,还是对方不断变化的内部实现。
如果仍在比较普通单体、模块化单体与微服务的整体运行形态,可以先看单体、模块化单体与微服务。进一步拆分时,重点就转向具体操作和数据,不必把已有模块一次性全部拆成进程。
拆开以后,哪些依赖仍然存在
接口调用有三种常见安排
同步请求适合调用方必须拿到结果才能继续的操作。订单要展示当前报价,可以立即调用定价接口。调用期间会占用线程或异步执行资源,也受网络、连接池和下游状态影响。连续增加同步依赖,会延长关键路径;并行扇出虽然缩短部分等待,也增加了同时占用的连接与下游工作。
异步事件适合告知已经发生的业务事实,例如订单已支付后通知履约。消费者可以稍后处理,也可能重复收到或暂时落后。因此事件需要业务标识、版本和明确含义,消费者需要去重与失败恢复。把 HTTP 换成消息后,原来的业务依赖仍然存在,只是等待方式和失败恢复过程发生了变化。
本地查询副本适合高频组合读取。订单列表可以保存所需的商品展示字段,减少逐条请求目录服务。副本要有更新来源和允许落后的程度;商品介绍稍晚更新与支付金额不一致,业务后果完全不同。数据管理中的这些取舍可结合 Microsoft 微服务数据设计查阅。
选择交互方式时,要把结果写具体:调用方需要当前值还是历史值,能等待多久,允许多旧,失败后可否继续。仅写“采用事件驱动”无法回答这些问题。
数据私有化需要真正限制访问
服务的数据私有化可以从独立账号和表访问权限开始,不一定立刻购买独立数据库服务器:
| 安排 | 得到的隔离 | 仍共享的部分 |
|---|---|---|
| 同一账号访问全部表 | 基本只有代码约定 | 权限、数据结构、事务、实例资源 |
| 同一数据库,不同 schema 与受限账号 | 可以阻止应用越权读写其他 schema | 数据库实例的 CPU、存储、连接容量和故障 |
| 同一集群,不同数据库和账号 | 连接目标与授权更独立 | 集群运维、资源与部分故障 |
| 独立数据库实例 | 可以分别扩容、维护和控制资源 | 网络、上游依赖及业务协作仍可能共享 |
PostgreSQL 的 schema 用于组织对象,权限才决定账号能否访问。具有授权的账号可以跨 schema 查询;只改表名前缀不会自动阻止访问。PostgreSQL schema 文档解释了这一关系。
业务应用通常只取得执行读写所需的权限。建表、改角色和跨域迁移使用另外的管理身份。为了修复一次查询失败而给应用授予超级用户,会让原本建立的数据划分失效。PostgreSQL 权限文档可用于核对对象权限与授权操作。
共享库也需要同样检查。错误码定义、HTTP 客户端和公开 DTO 可以帮助接入;共享 Repository、数据库实体或内部状态机,会迫使消费者跟随提供方一起升级。即使使用生成客户端,仍需测试旧客户端读取新响应、新客户端访问旧服务的组合。
跨服务操作怎样保留业务含义
在同一数据库连接中执行的本地事务,能够统一提交或回滚参与其中的 SQL。通过 HTTP 调用另一个服务后,该服务已经执行的操作不属于调用方的本地事务。
因此,跨服务业务通常需要显式状态。以库存预留为例:
创建待处理订单
→ 请求库存预留,携带业务操作标识
→ 得到预留标识后确认订单
→ 明确失败时关闭待处理订单
→ 结果未知时查询预留状态,或进入重试/核对任务这里的“结果未知”有实际含义:客户端没有收到响应,却不能据此判断库存服务有没有成功预留。盲目再发一个新业务标识可能重复占用库存;立即释放也可能与稍晚到达的成功操作竞争。
Saga、补偿操作、事务消息和 Outbox 分别解决其中不同问题。补偿需要业务允许撤销或抵消;一次退款也不能抹去已经发生的渠道扣款记录。数据库与消息之间的持续一致处理,可接着查阅从写入到一致状态。
哪些情况下值得拆,哪些情况下先保留模块
独立伸缩是一个具体理由。例如图片处理消耗大量 CPU,而订单写入主要受数据库约束,把前者拆成工作服务可以分别设置资源和扩容策略。高风险集成也可能适合隔离:外部供应商接口经常卡住,可以把连接和并发限制放进独立适配服务。
如果两个模块总是修改同一不变量、发布节奏相同、负载也相近,拆开后的独立收益可能很少。团队还要支付网络诊断、身份管理、接口兼容、数据恢复和多实例运维的成本。此时先修复模块公开接口、去掉跨模块表访问,往往就能解决当前问题。
判断可以落到近期的真实变更:哪些改动只属于候选模块,却迫使整个应用发布;哪些资源峰值可以分开;哪个依赖失败会拖住其他功能;拆开后是否仍需要同步变更全部消费者。结论来自这些具体约束,不存在适用于所有团队的“超过多少类就拆服务”规则。
让订单只能通过定价接口取得报价
运行对象与环境
完整工程:下载 ZIP。解压目录中的 README.md 列出源码与文件入口。
microservice-service-boundaries-lab/
├─ pom.xml Maven 父项目,编译目标 Java 17
├─ pricing/ 报价 HTTP 应用;只使用 pricing_app
├─ orders/ 订单 HTTP 应用;只使用 orders_app
├─ db/init.sh 创建角色、schema、表和初始价格
└─ compose.yaml 数据库与两个应用的运行配置环境为 Linux、Bash、Docker Engine 与 Compose v2,宿主机具有 curl、unzip、openssl 和 jq。curl --fail-with-body 要求 curl 7.76.0 或更新版本。镜像使用 Maven 3.9.12-eclipse-temurin-25、应用运行时 Temurin 25.0.4_7-jdk、PostgreSQL 18.6;Boot 为 4.1.1。Maven 镜像内的 JDK 补丁版本与应用运行镜像可以不同,java -version 的实际输出为准。Boot 的 Java 与构建要求见 Spring Boot 系统要求,数据库补丁选择见 PostgreSQL 版本支持。
数据库进程使用镜像中的 postgres 用户,所用 Linux 镜像中 UID/GID 为 999:999;两个 Java 应用使用 10001:10001。构建容器使用当前宿主用户的 UID/GID,Maven 缓存由该用户创建。数据库初始化角色 lab_admin 与业务账号分开,应用没有超级用户权限。
两个 HTTP 端口只绑定宿主机回环地址,数据库端口不映射到宿主机。服务没有实现用户登录或订单对象授权,仅适合独立实验环境。生产接入需要补齐认证、对象权限检查和 TLS,不能直接把端口改成公网监听。
数据库数据放在 tmpfs,停止或移除数据库容器后会丢失。它便于重复建立这套实验,不适合保存真实数据;持久环境应另行配置数据卷、备份与恢复。Docker tmpfs 文档说明了其生命周期。
1. 准备目录和运行身份
把 ZIP 下载到当前目录,在同一个 Bash 会话中执行后续命令:
test -f microservice-service-boundaries-lab.zip
test "$(id -u)" -ne 0 || { printf '%s\n' '请使用普通用户运行实验。' >&2; exit 1; }
docker version
docker compose version
curl --version
command -v unzip openssl jq
LAB_PARENT=$(mktemp -d /tmp/service-boundaries.XXXXXX)
unzip -q microservice-service-boundaries-lab.zip -d "$LAB_PARENT"
cd "$LAB_PARENT/microservice-service-boundaries-lab"
LAB_DIR=$(pwd)
M2_DIR="$LAB_PARENT/m2"
mkdir -p "$M2_DIR"
chmod a+r db/init.sh
export POSTGRES_PASSWORD=$(openssl rand -hex 24)
export PRICING_PASSWORD=$(openssl rand -hex 24)
export ORDERS_PASSWORD=$(openssl rand -hex 24)
export COMPOSE_PROJECT_NAME=ms13-boundary
docker run --rm --entrypoint id postgres:18.6 postgres最后一条命令应显示 uid=999(postgres) gid=999(postgres)。Compose 为数据库的临时目录设置了相同 UID/GID;换用其他变体镜像后,应重新检查并同步修改。不要只改 user 而忽略目录写权限。
实验密码保存在当前 shell 环境中,不需要打印。Compose 后续命令仍需这些变量;换终端时需要使用原实验凭据,重新生成密码不会修改已经初始化的数据库账号。不要把带密码的 docker inspect 或完整 Compose 展开结果复制到公开日志。
若机器已经有名为 ms13-boundary 的 Compose 项目,先换一个未使用的 COMPOSE_PROJECT_NAME,后续网络名会跟随该变量。Docker 下载失败时先处理镜像仓库连通性或企业批准的镜像同步,不更改应用版本来掩盖网络错误。
2. 初始化数据库,再构建两个应用
docker compose up -d --wait database
docker run --rm \
--user "$(id -u):$(id -g)" \
--network "${COMPOSE_PROJECT_NAME}_default" \
-e MAVEN_CONFIG=/m2 -e MAVEN_OPTS=-Duser.home=/tmp \
-e DB_URL=jdbc:postgresql://database:5432/boundary \
-e PRICING_PASSWORD -e ORDERS_PASSWORD \
-v "$LAB_DIR:/work" -v "$M2_DIR:/m2" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -Dmaven.repo.local=/m2 clean verify数据库先启动,是因为测试会通过真实 PostgreSQL 连接验证授权和订单保存。测试会清理实验订单表,不要把 DB_URL 指向已有业务数据库。
预期 Maven 结果包含:
PricingTests: Tests run: 3, Failures: 0, Errors: 0
OrdersTests: Tests run: 6, Failures: 0, Errors: 0
pricing ............................................ SUCCESS
orders ............................................. SUCCESS
BUILD SUCCESS产物分别为 pricing/target/pricing.jar 与 orders/target/orders.jar。如果 Maven 无法写缓存,检查挂载目录所属用户;如果出现数据库密码错误,检查当前 shell 凭据是否仍与初始化时一致。数据库未就绪时先查看 docker compose logs database,不要跳过测试继续启动。
初始化脚本把表留给管理角色拥有,只授予应用 DML 所需权限。下面是其中的关键授权:
GRANT USAGE ON SCHEMA pricing TO pricing_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON pricing.price TO pricing_app;
GRANT USAGE ON SCHEMA orders TO orders_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON orders.purchase TO orders_app;orders_app 没有 pricing schema 的使用权限,也没有该表的读取权限。两个 schema 的名字只负责组织对象,真正阻止跨域访问的是这份授权关系。
3. 启动服务,取得第一份报价
chmod a+r pricing/target/pricing.jar orders/target/orders.jar
docker compose up -d pricing orders
for PORT in 18131 18132; do
READY=0
for ATTEMPT in $(seq 1 30); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 1 --max-time 2 \
"http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1; then
READY=1
break
fi
sleep 1
done
test "$READY" -eq 1 || { docker compose logs pricing orders; exit 1; }
done
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
http://127.0.0.1:18131/quotes/BOOK-1预期报价:
{"sku":"BOOK-1","unitPrice":19.90,"currency":"CNY","priceVersion":1}/healthz 会检查自身数据库连接,不探测另一项业务服务。因此 orders 的该接口成功,只说明订单应用和它的数据库可用;创建订单所需的定价接口还要由实际请求检查。Compose 的 depends_on、健康条件与运行用户属于不同配置,完整字段定义见 Compose service 配置。
报价字段可直接拆开理解:
sku 报价对应的商品标识
unitPrice 当前这次报价的单价,使用十进制金额
currency 币种,不能由调用方猜测
priceVersion 价格规则或数据的版本标识这个查询接口读取当前价格,没有实现限时锁价。需要保证“某份报价在有效期内必定可成交”时,应增加报价标识、适用条件和有效期,并由提供方定义确认报价的操作;只有一个 priceVersion 字段还不具备这种业务承诺。
4. 创建订单,保存成交快照
ORDER_A=00000000-0000-0000-0000-000000000001
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 6 \
-H 'Content-Type: application/json' \
--data "{\"orderId\":\"$ORDER_A\",\"sku\":\"BOOK-1\",\"quantity\":2}" \
-w '\nHTTP %{http_code}\n' \
http://127.0.0.1:18132/orders预期 HTTP 201,响应中包含:
{
"orderId": "00000000-0000-0000-0000-000000000001",
"sku": "BOOK-1",
"quantity": 2,
"unitPrice": 19.90,
"currency": "CNY",
"priceVersion": 1,
"total": 39.80
}订单创建按这个顺序执行:
请求参数校验
→ 查询订单标识是否已经存在
→ HTTP 读取报价
→ 检查商品、金额、币种与版本
→ 计算订单总额
→ 以 orders_app 插入 orders.purchase
→ 返回订单及其查询地址报价读取使用 Spring RestClient:
Quote quote = pricing.get()
.uri("/quotes/{sku}", sku)
.retrieve()
.body(Quote.class);实际工程设置了连接和读取超时,并分别处理远端错误响应、传输失败与非法报价。Quote 是订单侧的通信模型,不引用定价侧的持久化类。客户端消息转换与错误处理 API 见 Spring REST Clients。
插入语句在本地数据库中原子执行。此时没有库存预留、支付或跨服务提交;这些业务操作要另行设计状态推进。相同订单标识再次提交返回 409,不会产生第二条订单。若第一次请求结果未知,先查询该订单标识,再决定怎样继续,不能把“重复标识被拒绝”理解成已经实现完整幂等响应缓存。
5. 用数据库账号验证访问限制
在订单服务使用的数据库身份下,直接读取定价表:
set +e
docker compose exec -T database sh -c \
'PGPASSWORD="$ORDERS_PASSWORD" psql -h 127.0.0.1 -U orders_app -d boundary \
-v ON_ERROR_STOP=1 -v VERBOSITY=verbose -c "SELECT * FROM pricing.price"'
DB_EXIT=$?
set -e
test "$DB_EXIT" -ne 0预期错误包含:
ERROR: 42501: permission denied for schema pricing42501 是 PostgreSQL 的 insufficient_privilege。如果看到的是连接失败或密码错误,还没有验证到对象权限,应先修复连接身份;完整分类见 PostgreSQL 错误码。如果查询成功,则需要检查是否误用管理员账号,或为应用添加了跨 schema 授权。
6. 改价后检查历史订单
使用定价服务自己的账号更新价格:
docker compose exec -T database sh -c \
'PGPASSWORD="$PRICING_PASSWORD" psql -h 127.0.0.1 -U pricing_app -d boundary \
-v ON_ERROR_STOP=1 -c "UPDATE pricing.price SET unit_price=29.90, price_version=2 WHERE sku='\''BOOK-1'\''"'
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
"http://127.0.0.1:18132/orders/$ORDER_A"预期 SQL 输出 UPDATE 1,已有订单仍是单价 19.90、版本 1、总额 39.80。再使用一个新标识创建订单:
ORDER_B=00000000-0000-0000-0000-000000000002
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 6 \
-H 'Content-Type: application/json' \
--data "{\"orderId\":\"$ORDER_B\",\"sku\":\"BOOK-1\",\"quantity\":2}" \
-w '\nHTTP %{http_code}\n' \
http://127.0.0.1:18132/orders新订单应采用版本 2,总额为 59.80。若历史订单也变成这个金额,检查订单查询是否重新请求当前报价或关联定价表,而没有读取保存的成交快照。
固定标识用于新建的实验数据库,重复执行创建命令会收到 409。不要为了让输出再次成功而随意清理其他数据;需要重新实验时使用新的订单标识,或在确认全部数据可丢弃后重建该实验数据库。
迁移、回退与常见耦合故障
先观察下游停止影响了哪些操作
docker compose stop pricing
ORDER_C=00000000-0000-0000-0000-000000000003
set +e
HTTP_CODE=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 6 \
-H 'Content-Type: application/json' \
--data "{\"orderId\":\"$ORDER_C\",\"sku\":\"BOOK-1\",\"quantity\":2}" \
-o pricing-unavailable.json -w '%{http_code}' \
http://127.0.0.1:18132/orders)
CURL_EXIT=$?
set -e
test "$CURL_EXIT" -eq 0
test "$HTTP_CODE" = 503
jq -e '.detail == "PRICING_UNAVAILABLE"' pricing-unavailable.json先检查 curl 传输成功,再检查 HTTP 503 和具体错误体。若 curl 自身超时、连接被拒绝或返回 000,失败发生在另一层,不能当成预期的定价依赖错误。-q 禁止默认 curl 配置文件,--noproxy '*' 禁用代理路径;相关行为见 curl 官方手册。
这条失败路径尚未执行订单插入。可以检查该标识不存在,同时确认旧订单仍可查询:
set +e
HTTP_CODE=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 \
-o absent-order.json -w '%{http_code}' \
"http://127.0.0.1:18132/orders/$ORDER_C")
CURL_EXIT=$?
set -e
test "$CURL_EXIT" -eq 0
test "$HTTP_CODE" = 404
jq -e '.detail == "ORDER_NOT_FOUND"' absent-order.json
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
"http://127.0.0.1:18132/orders/$ORDER_A"已有订单查询只依赖订单数据库,因此仍返回 200。新建订单需要定价服务,所以暂时失败。这种按功能区分的影响,比“orders 进程还活着”更接近实际可用性。
恢复定价服务:
docker compose start pricing
READY=0
for ATTEMPT in $(seq 1 30); do
if curl -q --noproxy '*' --silent --fail-with-body \
--connect-timeout 1 --max-time 2 \
http://127.0.0.1:18131/healthz >/dev/null 2>&1; then
READY=1
break
fi
sleep 1
done
test "$READY" -eq 1 || { docker compose logs pricing; exit 1; }
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 6 \
-H 'Content-Type: application/json' \
--data "{\"orderId\":\"$ORDER_C\",\"sku\":\"BOOK-1\",\"quantity\":2}" \
-w '\nHTTP %{http_code}\n' \
http://127.0.0.1:18132/orders此前不存在的订单应创建成功。实验中定价只做读取,所以失败前没有需要补偿的远端写入;换成库存预留或支付请求后,必须增加业务结果查询与恢复过程。
从旧模块逐步迁出
渐进迁移可以先抽出公开接口,再切换调用,最后迁移数据。外层路由把部分操作转给新实现,旧实现继续承接未迁移部分,这种替换方式通常称为 Strangler Fig。AWS 渐进替换模式提供了结构说明。
| 阶段 | 具体动作 | 需要保留的约束 |
|---|---|---|
| 建立接口 | 旧模块先提供稳定的业务 API,清点直接访问表的调用者 | 旧模块继续独占写入,不同时改变全部业务含义 |
| 准备新存储 | 建表、回填历史数据,通过增量同步追上变化 | 标识转换、删除、乱序与重复都要有处理办法 |
| 对照读取 | 新实现处理影子读取,比较金额、状态和缺失项 | 影子路径不执行真实业务写入或外部通知 |
| 切换部分操作 | 按明确的一组业务标识把读写转给新实现 | 同一业务记录的写入归属清晰,避免两边同时接受修改 |
| 扩大新路径 | 观察结果、延迟、错误和积压,再逐步扩大 | 兼容旧消费者,保留结果查询与异常恢复 |
| 停用旧路径 | 清除旧调用、同步任务和旧权限 | 先确认没有旧进程、定时任务或人工工具仍在写入 |
数据迁移比移动 Controller 更容易留下隐蔽调用者。定时任务、导入脚本、后台管理工具和人工 SQL 都可能仍使用旧表。数据库访问日志、账号权限和应用依赖搜索需要一起检查。
迁移期间如果必须双写,必须回答第一边成功、第二边失败后怎样修复,以及谁提供最终查询结果。仅在同一方法里连续调用两个 Repository,无法获得跨系统原子提交。
回退代码之前检查数据还能否被旧版本解释
新增字段通常可以先让旧代码忽略,再升级读取方;删除字段或改变状态含义需要更长的兼容窗口。例如新版本增加“部分退款”状态,旧版本只有“已支付”和“已退款”,回退旧 JAR 后可能无法正确处理已有记录。
常用的扩展—迁移—收缩过程是:先增加新字段和兼容读写,再迁移数据与消费者,确认旧路径消失后才删除旧字段。回退窗口内保留旧版本需要的字段,并验证旧版本实际能读取新版本写入的数据。
如果新服务已经成为唯一写入方,而旧数据库没有继续同步,流量切回旧服务只会读到过期数据。此时可采取暂停相关写入、校正数据后切回,或保留新写路径并向前修复。恢复方案取决于已发生的数据变化,不能仅由部署平台的“回滚”按钮决定。
常见失败按对象定位
| 现象 | 先看什么 | 下一步 |
|---|---|---|
| 数据库拒绝跨 schema 查询 | 当前数据库用户与 SQLSTATE | 确认是否误走了直接查询;通过服务接口获取所需数据,不立即扩权 |
| 新增接口字段后旧客户端失败 | 实际响应、反序列化配置与旧客户端契约测试 | 保留兼容字段或调整客户端容错,检查新增枚举值的处理 |
| 两个服务必须总是一起发布 | 公共包、共享表、必需字段和同步调用关系 | 把内部模型移出共享契约,建立兼容演进顺序 |
| 一个服务变慢拖住其他功能 | 连接池等待、线程占用、下游调用时间 | 限制相关并发和等待,评估异步处理或本地查询副本 |
| 订单历史金额随改价变化 | 查询路径是否使用当前价格 | 保存并读取成交快照,核对历史数据修复方式 |
| 切流后仍有旧数据被写入 | 旧账号活动、定时任务、管理工具和残留实例 | 逐一切换或停用调用者,再撤销旧写权限 |
| 回退后出现未知状态 | 新版本已写数据及旧版本解析逻辑 | 暂停受影响操作,选择兼容修复或数据校正 |
服务接口的错误率需要与具体业务操作关联。创建订单失败、已有订单查询失败、报价过期和数据库授权拒绝代表不同问题;混成一个“服务错误数”会掩盖恢复方向。进一步的实例选择和保护策略见负载均衡、超时与重试与限流、熔断、隔离与降级。
停止并清理实验
确认实验数据不再需要后,在原目录和保留凭据的 shell 中执行:
docker compose down
unset POSTGRES_PASSWORD PRICING_PASSWORD ORDERS_PASSWORD这会停止并移除本项目的三个容器及其网络,数据库 tmpfs 数据不可恢复。ZIP、源码、JAR 和 Maven 缓存仍留在 LAB_PARENT,不会删除其他项目。若要保留订单结果,先通过查询接口导出需要的实验输出,再停止数据库。
权威资料与规范地址
领域划分、数据库授权、客户端 API 与运行配置可分别查阅以下资料。
| 资料 | 完整地址 |
|---|---|
| Microsoft:领域分析与限界上下文 | https://learn.microsoft.com/en-us/azure/architecture/microservices/model/domain-analysis |
| Microsoft:微服务数据设计 | https://learn.microsoft.com/en-us/azure/architecture/microservices/design/data-considerations |
| PostgreSQL:schema | https://www.postgresql.org/docs/18/ddl-schemas.html |
| PostgreSQL:对象权限 | https://www.postgresql.org/docs/18/ddl-priv.html |
| PostgreSQL:错误码 | https://www.postgresql.org/docs/18/errcodes-appendix.html |
| PostgreSQL:版本支持 | https://www.postgresql.org/support/versioning/ |
| Spring Boot:系统要求 | https://docs.spring.io/spring-boot/system-requirements.html |
| Spring Framework:REST Clients | https://docs.spring.io/spring-framework/reference/integration/rest-clients.html |
| Docker:Compose service 配置 | https://docs.docker.com/reference/compose-file/services/ |
| Docker:tmpfs 生命周期 | https://docs.docker.com/engine/storage/tmpfs/ |
| curl:命令与配置 | https://curl.se/docs/manpage.html |
| AWS:Strangler Fig 渐进替换 | https://docs.aws.amazon.com/prescriptive-guidance/latest/modernization-decomposing-monoliths/strangler-fig.html |
