接口演进、分页与审计:版本、游标和写入结果
把响应字段 name 改成 displayName,会影响旧客户端;在列表前面插入一条记录,会改变第二页的偏移;一次创建请求重试,又可能产生第二笔订单。这些问题发生在不同位置,却都与接口已经对外作出的约定有关。
接口演进需要保留调用者仍在使用的行为。版本、分页位置和写入结果各自保存什么,应先说明清楚,再选择框架配置和数据库实现。
版本选择与兼容变化
哪些改动会影响已有客户端
增加可选字段通常比改名容易兼容,但还要考虑客户端的反序列化策略。严格解析器可能拒绝未知字段;新增枚举值也可能进入客户端没有处理的分支。
| 修改 | 应检查的已有行为 |
|---|---|
| 新增字段 | 旧客户端是否忽略未知字段,是否有默认值 |
| 字段改名或删除 | 原名称是否仍被使用,是否保留迁移窗口 |
| 整数改字符串 | 解析、排序与计算逻辑是否仍可用 |
| 金额单位变化 | 原调用者是否仍按分、元或某个币种计算 |
| 新增枚举值 | 未知值是否能够被安全处理 |
| 放宽输入范围 | 下游存储和旧消费者是否能接受新增值 |
| 修改状态码、错误格式 | 客户端重试、提示和监控是否受影响 |
“JSON 能解析”只覆盖语法和类型的一部分。金额从分改成元,即使仍是整数,也已经改变业务含义。版本升级前,用当前仍支持的客户端读取新响应,检查实际行为,而不只比较字段列表。
服务端与客户端通常无法同时升级。先增加兼容读取或双字段输出,迁移消费者,再删除旧格式,是常见的扩展—迁移—收缩顺序。保持多久取决于调用者升级周期和接口支持约定。
在路径、请求头和媒体类型中选择版本
路径 /v1/orders 直观,地址本身携带版本;请求头 API-Version 可以保持资源路径不变;媒体类型参数则把版本放在表示协商中。它们没有统一的 HTTP 强制形式,选择后应让网关、缓存、日志和客户端一致理解。
实验采用 Framework 7 内置 API Versioning:
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer.useRequestHeader("API-Version")
.setDefaultVersion("1.0")
.detectSupportedVersions(false)
.addSupportedVersions("1.0", "2.0");
}未发送版本头时使用 1.0。显式支持列表只有 1.0、2.0,关闭了从映射自动推导支持版本的行为,发送 9.0 会在方法调用前被拒绝。默认解析使用语义版本解析器;不要自己拿字符串按字典顺序比较 2 和 10。API Version 配置列出了解析来源、支持版本和废弃处理接口。
这个配置作用于该 MVC 配置下的请求,不仅是某个 Controller 的局部开关。把版本框架接入已有应用时,还要检查未版本化接口、健康端点和管理端点收到版本头后的行为。
固定版本与基线版本
@GetMapping(path = "/profile", version = "1.0")
ResponseEntity<Map<String, Object>> v1() {
return ResponseEntity.ok().varyBy("API-Version")
.body(Map.of("name", "reader"));
}
@GetMapping(path = "/profile", version = "2.0")
ResponseEntity<Map<String, Object>> v2() {
return ResponseEntity.ok().varyBy("API-Version")
.body(Map.of("displayName", "reader", "locale", "zh-CN"));
}固定版本只匹配指定版本;类似 1.2+ 的基线映射,可承接该版本及更高的受支持版本,直到被更具体的映射取代。基线映射不会让任意未来版本自动变成受支持版本。完整选择规则见 版本映射。
请求头决定响应内容时,缓存必须把这个差异纳入选择。实验响应带 Vary: API-Version,使遵守 Vary 的缓存区分不同请求头值;它本身不决定是否允许缓存,也不代替用户私有数据的 Cache-Control 策略。缓存键和 Vary 的协议行为见 HTTP Caching。
版本废弃可以通过 Deprecation、Sunset 和相应 Link 告知客户端。前者表达废弃信息,Sunset 表达预期停止服务的时间;仅添加头部不会停止流量,也不能替代迁移沟通。字段格式分别由 RFC 9745 和 RFC 8594规定,不应手工编造日期格式。
分页位置怎样对应数据变化
offset 分页计算的是当前结果集的位置
select id
from api_product
order by id desc
offset ? rows fetch next ? rows only排序是分页的前提。没有 ORDER BY,数据库不承诺返回次序;只按非唯一的时间排序,时间相同的记录也可能互换位置,应增加唯一的次级排序键。
offset=2、size=2 表示跳过当前结果集的前两条,再取两条。若每次请求分别查询数据库,前面发生插入或删除,就会改变这个位置所对应的数据:
第一次查询:5, 4, 3, 2, 1
第一页 offset=0,size=2 → 5, 4
新插入 id=6
当前数据:6, 5, 4, 3, 2, 1
第二页 offset=2,size=2 → 4, 3
↑ 4 重复这不是数据库没有遵守 SQL,而是第二次 offset 面向新的结果集。深 offset 还可能需要扫描并跳过大量记录;是否高效要看实际查询计划及索引,不能只看返回两条数据就推断工作量很小。分页语法、排序和限制的关系可查 PostgreSQL LIMIT/OFFSET;实验用 H2 执行对应标准形式 SQL。
keyset 游标保存最后一个排序位置
若 id 是不可变且唯一的降序键,上一页最后一个 id=4,下一页可以查询:
select id
from api_product
where id < ?
order by id desc
fetch next ? rows only即使前面新增 id=6,条件仍取得 3、2,不会重新读到 4。范围条件也有机会直接利用对应索引。
游标的选择必须跟 ORDER BY 一致。按 created_at、id 降序时,需要同时保存两个值,条件为时间更早,或时间相同但 id 更小。若排序字段 updated_at 会在浏览过程中变化,记录仍可能移到游标前后;换成游标没有消除这个变化来源。
需要整个导出过程保持同一份快照时,应另外使用数据库快照、导出任务或冻结的标识集合。普通 Web 翻页通常接受当前数据的变化,但要清楚说明删除、新增和状态筛选变化怎样影响结果。
游标还要绑定查询条件
真正的游标通常包含排序键、筛选条件摘要和格式版本。租户、权限、排序方向变化后,旧游标不应继续用于另一份结果集。
Base64 只是编码。客户端可修改游标内容时,需要验证允许值;若服务端依赖游标携带的条件完整性,可以使用签名或不透明服务端令牌。资源访问仍要重新做权限检查,不从游标推断用户可以读取某个订单。
实验为便于观察,直接使用 after=4 表达单个排序位置。/api/products/cursor 没有租户或复杂筛选,不把这个简单参数冒充完整生产游标。size 限制为 1–20,offset 限制为 0–10000,避免一个请求任意放大扫描或响应体。
total 总数是另一次查询时,可能与列表查询看到的状态不同,还会增加 count 的成本。只需要“是否还有下一页”的界面,可以多取一条生成 hasNext;需要精确总数时再定义一致性和性能要求。
重复写入与审计记录
同一个键、同一输入和同一结果
网络断开后,客户端可能不知道第一次创建是否成功。实验要求 Idempotency-Key,并在数据库中保存该键对应的首次响应:
api_receipt
├── actor + request_key:联合主键
├── sku + quantity:本次输入的规范化字段
└── response_status + response_body:首次已提交响应
同键同输入 → 返回保存的状态与主体
同键不同输入 → 409
新键 → 创建订单,并保存首次结果键的作用域包含服务器取得的 actor,防止不同用户碰巧使用相同字符串互相影响。实验使用固定服务端主体 lab-user,没有实现登录;生产中应替换为经过认证的主体,并把操作类型、API 版本和必要业务范围纳入键或输入指纹。
输入指纹应基于规范化业务字段,而不是把 JSON 字符串直接比较;空格、字段顺序和可接受的缺省值不应意外改变同一次业务操作的含义。这里直接保存并比较 sku、quantity,便于从数据库中观察。
订单、幂等结果和成功审计一起提交
开始本地事务
→ 查找已提交的相同键
→ 不存在时插入 api_receipt,唯一键协调并发竞争
→ 插入 api_order
→ 插入 api_audit
→ 保存首次响应 JSON 和状态
提交事务
→ 发送 HTTP 响应实验通过 TransactionTemplate 和 JdbcTemplate 使用同一事务连接。若在提交前抛出异常,三张表的修改一起回滚。JdbcTemplate 负责资源管理和异常转换,相关使用方式见 Spring JDBC Core,事务回调见 编程式事务。
仅先 SELECT 再 INSERT 仍有竞争窗口,因此 api_receipt 的联合主键不可省略。两个请求同时发现键不存在时,数据库唯一约束决定哪一个成功创建;失败方退出并回滚自己的事务后,再读取已提交的成功结果。不要在一个已经失败的事务里继续盲目查询。
HTTP 响应在提交后发送。即使发送失败,数据库中的首次结果仍可用于下次重试;相反,不能先把成功响应发给用户,再尝试提交订单。
这套实验只协调本地数据库写入。跨服务扣款、发送消息和生成外部文件并不自动加入这个事务,应结合 Outbox、业务操作键和查询恢复处理。相关路径见 消息投递与幂等。
审计记录描述已发生的业务操作
成功审计与订单一起提交,可以避免订单已存在但成功记录缺失。实验保存 actor、ORDER_CREATED 和订单标识;同一幂等请求重放只读取原结果,不新增第二条成功记录。
拒绝和失败记录需要另行决定保存位置。若把“操作被拒绝”也放在随后整体回滚的事务中,记录会一起消失。可以通过独立受限审计通道保存尝试结果,但要定义写入失败时是否影响业务,以及怎样避免敏感信息进入日志。
完整审计通常还包含资源、动作、结果、受控请求关联以及可信的时间来源,保存必要的变更摘要。审计存储应限制修改和删除,约定保留期;普通调试日志轮转机制不一定满足同样要求。数据选择与防篡改等考虑见 OWASP Logging。
在真实映射与数据库上验证
启动和版本请求
下载 MVC 实验工程,按Linux 准备步骤解包、编译并启动 mvc09-binding。所需工具是 Bash、Docker、curl、jq;Boot 4.1.1 使用 Framework 7.0.9,数据库为工程内 H2。默认使用内存数据库,应用进程结束后数据丢失。
BASE=http://127.0.0.1:18091
curl -q --noproxy '*' --fail-with-body --max-time 5 -i \
"$BASE/api/profile"
curl -q --noproxy '*' --fail-with-body --max-time 5 -i \
-H 'API-Version: 2.0' "$BASE/api/profile"第一条得到 name=reader,第二条得到 displayName=reader、locale=zh-CN;两条响应带 Vary: API-Version。使用 API-Version: 9.0 应得到 400,而不是默默选择最近版本。
正常请求使用 --fail-with-body;预期错误时分开检查传输和状态。-q 必须放第一个选项,--noproxy '*' 隔离代理环境。curl 手册提供这些选项的定义。
看分页,再运行并发插入对照
curl -q --noproxy '*' --fail-with-body --max-time 5 \
"$BASE/api/products/offset?offset=0&size=2"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
"$BASE/api/products/cursor?after=4&size=2"新启动的数据库含 id=1–5,响应分别为 [5,4] 与 [3,2]。实验不开放任意 SQL 的 Web 入口;插入新行的对照由 ApiEvolutionTest 在独立测试数据库中执行。
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=ApiEvolutionTest testPROJECT_DIR 与 LAB_DIR 来自准备步骤。测试依次读取第一页、插入 id=6,再断言 offset 返回 [4,3]、after=4 返回 [3,2]。同一测试类还运行两个并发事务,确认相同键只创建一笔订单和一条审计;故意在提交前失败则三张表全部回滚。
创建并重放首次响应
RESULTS="$(mktemp -d)"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'Content-Type: application/json' -H 'Idempotency-Key: create-01' \
--data '{"sku":"SKU-001","quantity":2}' \
-D "$RESULTS/first.headers" -o "$RESULTS/first.json" "$BASE/api/orders"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'Content-Type: application/json' -H 'Idempotency-Key: create-01' \
--data '{"sku":"SKU-001","quantity":2}' \
-D "$RESULTS/replay.headers" -o "$RESULTS/replay.json" "$BASE/api/orders"
cmp "$RESULTS/first.json" "$RESULTS/replay.json" || exit 1
cat "$RESULTS/replay.headers"
jq . "$RESULTS/replay.json"
curl -q --noproxy '*' --fail-with-body --max-time 5 "$BASE/api/audit"两次状态都为首次保存的 201,第二次头部 Idempotency-Replayed=true,主体逐字节一致;审计只有对应的一条 ORDER_CREATED。这个响应不是重新计算当前订单后生成的,所以后续状态变化不会悄悄改变首次创建结果。
用同一键改变 quantity,预期 409:
STATUS="$(curl -q --noproxy '*' -sS --max-time 5 \
-H 'Content-Type: application/json' -H 'Idempotency-Key: create-01' \
--data '{"sku":"SKU-001","quantity":3}' \
-o "$RESULTS/conflict.json" -w '%{http_code}' "$BASE/api/orders")"
TRANSFER=$?
test "$TRANSFER" -eq 0 || exit 1
test "$STATUS" = 409 || exit 1
cat "$RESULTS/conflict.json"若读取不到首次结果,检查是否重启过默认内存数据库。生产中的幂等记录需要持久存储,并将保留期与客户端重试、消息重放及业务可恢复窗口对齐。记录过期以后,同一字符串能否再次用于创建,应由接口明确约定。
使用文件数据库检查进程重启
H2 支持文件数据库。可以在新的回环端口启动另一个实验实例,使用宿主普通用户的 UID/GID,使数据库目录保持可写:
DATA_DIR="$(mktemp -d)"
start_persistent_lab() {
docker run -d --name mvc09-persist \
--user "$(id -u):$(id -g)" --read-only --tmpfs /tmp:rw,mode=1777 \
-p 127.0.0.1:18092:8080 \
-e 'SPRING_DATASOURCE_URL=jdbc:h2:file:/data/mvc' \
-v "$DATA_DIR:/data" \
-v "$PROJECT_DIR/target/mvc-runtime-lab-1.0.0.jar:/app/app.jar:ro" \
eclipse-temurin:25.0.4_7-jdk java -jar /app/app.jar
}
start_persistent_lab
docker logs --tail 40 mvc09-persist确认 /lab/health 成功后,将前面的 BASE 改为 http://127.0.0.1:18092,创建一次 create-01。随后只停止并删除该容器,再调用同一个函数:
docker stop mvc09-persist
docker rm mvc09-persist
start_persistent_lab等待新进程健康后,保持同一键和输入重发,应该得到保存的响应和 Idempotency-Replayed=true。DATA_DIR 中的 H2 文件仍然存在;不要让两个应用进程同时按此嵌入式方式打开同一文件库。文件 URL 与打开模式见 H2 Features。
结束时停止、删除 mvc09-persist,再执行 unset -f start_persistent_lab。数据库文件保留在 DATA_DIR,里面包含本次实验订单;这与默认内存模式的数据生命周期不同。
接口升级后出现问题时
| 现象 | 先核对的对象 | 下一步 |
|---|---|---|
| 旧客户端字段为空 | 版本头、实际响应和客户端模型 | 保留兼容字段,验证旧客户端 |
| 不同版本偶尔收到相同缓存 | 缓存键、Vary、代理是否转发版本头 | 纠正缓存选择并处理旧缓存 |
| 第二页重复 | 排序是否唯一、两次查询之间的插入删除 | 选择合适游标或固定快照 |
| 游标后漏项 | 可变排序字段、筛选条件变化 | 明确变化语义,必要时重新查询 |
| 同键产生多笔订单 | 数据库唯一约束与事务范围 | 让判重、业务写入、结果记录原子提交 |
| 失败后键一直被占用 | 是否提前独立提交了处理中记录 | 增加可恢复状态或收回同一事务 |
| 成功审计缺失 | 是否与业务提交分离 | 明确事务内记录或可靠投递 |
| 重启后重复创建 | 幂等数据是否只在内存中 | 恢复持久化结果,再处理未知请求 |
修改接口以后,旧格式、默认版本、重复请求和历史数据都应保留实际测试。它们代表调用者已经在依赖的行为,比只测试新接口首次成功更能发现升级影响。
