贯通案例:配置、安全与事务下单
单个下单请求要同时穿过四条边界:配置能否让服务合法启动、身份与租户能否裁决到具体资源、库存扣减与订单写入是否在同一个物理事务里、重复与并发请求会不会产生第二份效果。任何一条边界单独“看起来正常”,都不能推出整条链路正确——端口在监听、健康检查 200、接口返回订单号,都不等于库存与订单同事务、不等于跨租户请求被拒绝、不等于重复请求只扣一次。
本文是通用贯通案例的第 1 篇(PRD §8.2 前 3 阶段:配置与启动、接口与安全、数据与事务),用合成的“下单/扣库存”业务把已验模块串起来,不重写第二套机制教材:配置来源与绑定时点归 Boot 配置、请求校验与统一错误归 MVC 消息转换与 MVC 异常、过滤链与令牌验证归 OAuth2 与 OIDC、请求/方法/对象/租户授权归 资源授权、事务代理与资源绑定归 Spring 事务、会话与缓存归 MyBatis。消息与恢复(阶段 4)归 U80,观测与部署(阶段 5–6)归 U81,本文不预支。
固定版本:JDK 21(Liberica 21.0.11+11)/ Boot 4.0.9 / Framework 7.0.10 / Security 7.0.8 / MyBatis 3.5.19 + mybatis-spring 4.0.0 + starter 4.0.1 / mysql-connector-j 9.7.0 / Tomcat 11.0.27 / HikariCP 7.0.2 / JUnit Jupiter 6.0.3,服务端 MySQL Community 8.4.7(DBngin,REPEATABLE-READ、innodb_flush_log_at_trx_commit=1)。组合来源见新增工程基线,2026-10-27 的真实运行见实验工程与 labs/order-flow/evidence/u79-2026-10-27.json(19 个测试、22 条观察行、exit 0)。U42(MyBatis 全目录)与 U52(MySQL 事务/日志/恢复深度)尚未实施:本文只使用其最小事实并标注“待 U52 验证”,不冒充实机。
一、业务不变量先于任何组件
合成业务只有两张核心表:stock(tenant, sku, available, unit_price, version) 与 orders(tenant, sku, quantity, amount, status, idempotency_key),加一张 order_idempotency(idempotency_key, tenant, request_hash, order_id)。不变量写成可判定的四条:
- 不超卖:对任意租户与 SKU,
available ≥ 0,且初始库存 − 当前 available = 成功订单数量之和。 - 不重复:同一租户、同一
Idempotency-Key,业务效果(库存扣减 + 订单)恰好发生一次;同键异参是冲突,不是新请求。 - 不越权:一个请求只能创建或读取自己可信租户 claim 下的资源;请求体里的
tenant字段不参与裁决。 - 不假装成功:失败必须留下可解释的类别(输入 400、身份 401、权限 403、对象不存在 404、业务冲突 409/422、基础设施超时 503),不能用 200 或“进程还活着”代替。
这四条不变量先于选择框架:换 ORM、换鉴权方式、换数据库都不改变它们;反过来,任何组件组合都要拿它们验收。验证入口就是这四条,而不是某个接口返回了 JSON。
二、配置与启动:能启动 ≠ 可服务
绑定校验在启动阶段失败
第一个失败面发生在业务代码之前。OrderProperties 用 @Validated @ConfigurationProperties(prefix = "case.order") 绑定 tenant、maxItemsPerOrder、stockWarnThreshold;@NotBlank/@Min/@Max 在绑定阶段校验,缺项或越界直接让上下文创建失败,不会带着半份配置进入业务。实验里把 case.order.tenant 显式置空(传空串)后启动,失败链是 BeanCreationException → ConfigurationPropertiesBindException → BindException,根因报 Property: case.order.tenant ... Reason: 不能为空——这就是“非法配置启动失败”的可定位证据,不是等第一个请求 500 才发现。
# 测试通过独立短命上下文复现(web=none,无需 Web 端口)
U79 missingConfigFail=PASS bindsValidationAtStartup=true端口监听、探针与可服务
第二个边界是能启动与服务可用的区别。Boot 4 把 Actuator 的 Health 模型迁到 org.springframework.boot.health 包;实验自定义 DbReadyIndicator,把数据库探针挂进 readiness 组(management.endpoint.health.group.readiness.include=readinessState,db)。用不可达的数据源 URL 启动时,进程照常监听端口,但:
U79 badDbReadinessDown=PASS livenessUp=true portListensButNotServing=true/actuator/health/liveness 是 200 UP,/actuator/health/readiness 是 503 DOWN。这两个探针的消费者不同:liveness 失败意味着“重启我”,readiness 失败意味着“别把流量给我”。端口监听、进程存活、业务依赖可用是三个不同时点;只把端口写进负载均衡,等于把请求送进一个还没有数据库的服务。自定义 DbReadyIndicator 的 bean 名是 dbReadyIndicator,不会自动出现在 readiness 组里——必须显式 include。首版配置漏了这一点:探针类写好了却被 readiness 忽略,实际答复的是 Boot 自动配置的 db(DataSourceHealthIndicator);这也说明“写了探针”与“探针在生效”是两件事。
配置来源的优先级、spring.config.import、Profile 分组、record 与 JavaBean 绑定时点的差异,全部归 U23 权威页;本案例只锁定“哪组配置缺失必须挡住启动、哪个探针反映业务依赖”这两个组合结论。
三、接口与身份:谁的身份、哪个租户
输入校验与统一错误
请求体的第一次负例:{"sku":"","quantity":0} 得到 400,响应体带字段级消息 fields=sku,quantity。校验用 Jakarta Bean Validation 在参数绑定后、控制器方法前执行,失败由 @RestControllerAdvice 统一转成稳定结构;错误响应不回显 SQL、堆栈或异常对象。
令牌验证的失败分类
身份链的负例分四类,全部是真实签名(HS256 对称密钥,仅实验)的合成 token:
| 输入 | 实际结果 | 说明 |
|---|---|---|
无 Authorization | 401 | 资源服务器对缺失 Bearer 直接拒绝,不进授权判断 |
| 过期 token | 401 | exp 校验失败 |
错 iss | 401 | issuer 校验失败 |
错 aud | 401 | audience 必须显式校验,见下 |
缺 orders.write scope | 403 | 已认证但无权,是授权失败不是认证失败 |
错 audience 这一行值得单独讲。代码原本只写了:
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer));首轮测试里“错 audience”请求返回 200,暴露了一个真实缺口。Framework/Security 的实现是(固定标签 7.0.8):
public static OAuth2TokenValidator<Jwt> createDefaultWithIssuer(String issuer) {
return createDefaultWithValidators(new JwtIssuerValidator(issuer));
}createDefaultWithIssuer 组合默认校验器(iss、存在时的 exp/nbf、typ,以及 cnf.x5t#S256 存在时的证书指纹),但不含 audience 校验。修正后组合 DelegatingOAuth2TokenValidator(createDefaultWithIssuer(iss), JwtClaimValidator("aud", ...)),错 audience 立刻变 401。这条修正属于组合契约:每个校验器只保证自己那一项,把方法名当成“全套验证”就是本次实测抓到的误判。
租户与对象裁决
控制器从 SecurityContext 取可信主体,请求体里的 tenant 字段完全忽略。注意本实验把租户绑定到 sub:JwtAuthenticationConverter 默认 principal 取 sub,getName() 返回它;token 里的 tenant claim 没有参与裁决。真实系统应使用经发行方核验的独立 tenant claim,或使用 (issuer, sub) 这类不会在不同租户重名的键,并显式拒绝缺少该键的身份——本实验的“缺 sub”分支如实表现为 tenant=null 查不到任何行(业务 409),不是认证拒绝:
@PostMapping("/api/orders")
@PreAuthorize("hasAuthority('SCOPE_orders.write')")
public ResponseEntity<OrderResponse> create(
@CurrentSecurityContext SecurityContext context, ...) {
String trustedTenant = context.getAuthentication().getName(); // 来自 sub/tenant claim
...
}实验验证了跨租户的三种表象:
U79 crossTenant=PASS crossRead=404 selfRead=200 createConflict=409- t1 的 token 请求 t2 的 SKU:SQL 的
WHERE tenant=?使它看不到那行库存,条件扣减影响 0 行,返回 409insufficient_stock——不因为“这是 t2 的货”而泄露存在性; - t1 token 读 t2 的订单:
findOrder(id, tenant)双条件查不到,与“订单不存在”返回同一个 404; - t2 token 读自己的订单:200。
“同租户他人对象”在合成模型里表现为同一租户内不同用户的订单读取。本案例的 schema 没有 owner 列,因此把“同租户他人”折叠进“对象归属由 tenant+id 双条件裁决”的同一机制:服务端永远不信客户端给的归属字段,HTTP/Service 层也不提供“按 id 裸查”的入口。ACL、@PostAuthorize、Run-As、Spring Data 派生查询的边界由 U30 权威页承担,这里只落地“可信租户来自 claim、SQL 强制 tenant 条件”两条。
日志脱敏靠装配而不是事后过滤
S03 要求日志不输出凭据,这个保证来自装配而非事后过滤:CommonsRequestLoggingFilter 显式 includeHeaders=false、includePayload=false,只记录方法与 URI;错误分支日志只印稳定 code(如 order rejected code=insufficient_stock status=409),不印 token、Cookie 或 SQL;未预期异常只记录异常类型名,不打印可能携带 SQL/参数的消息体。“没看到日志里的 token”不是靠人肉检查,而是靠过滤器与日志语句的装配。
四、事务边界:四个写入必须同生共死
一个事务方法里的四步写入
下单成功路径在一个 @Transactional(timeout = 5) 服务方法里完成四步:幂等预查 → 原子条件扣减 → 插订单 → 插幂等记录并回填 order_id。四步共 7 条 SQL(含 4 条写语句:扣减、插订单、插幂等、回填);预查与回查是读。多数据源、远程调用都不在其中——它们不会自动加入这个物理事务,这一点归各机制页。
事务拦截的骨架在 Framework 7.0.10 里没有变(固定标签 v7.0.10):
if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager cpptm)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
Object retVal;
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, invocation, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
if (retVal != null && txAttr != null) {
TransactionStatus status = txInfo.getTransactionStatus();
if (status != null) {
if (retVal instanceof Future<?> future && future.isDone()) {
try {
future.get();
}
catch (ExecutionException ex) {
Throwable cause = ex.getCause();
Assert.state(cause != null, "Cause must not be null");
if (txAttr.rollbackOn(cause)) {
invocation.onRollback(cause, status);
status.setRollbackOnly();
}
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
else if (VAVR_PRESENT && VavrDelegate.isVavrTry(retVal)) {
// Set rollback-only in case of Vavr failure matching our rollback rules...
retVal = VavrDelegate.evaluateTryFailure(invocation, retVal, txAttr, status);
}
}
}
commitTransactionAfterReturning(txInfo);
return retVal;对本案例要紧的是三个时点:createTransactionIfNecessary 在业务方法之前(连接从池里借出、autoCommit 切成 false、绑定到线程),目标方法抛 RuntimeException 走 completeTransactionAfterThrowing(回滚),正常返回后在 finally 之后 commitTransactionAfterReturning(提交本身也可能失败,归事务权威页)。OrderBusinessException 继承 RuntimeException,因此库存不足、幂等冲突都会回滚;如果把它写成 checked exception,默认规则会让它提交——这是“异常类型决定回滚”的真实陷阱。
失败点必须在回滚之后断言
“扣了库存之后才失败”的可复现证据:预置一条同键订单,然后用同一 Idempotency-Key 下单 quantity=2。服务先执行条件扣减(10→8),随后 insertOrder 撞 uk_order_idem 唯一约束,抛 DuplicateKeyException,整个事务回滚:
U79 midFailureRollback=PASS deductedThenRolledBack stock=10 idemRows=0 orders=1库存回到 10、幂等表没有新行、订单表只有预置的那一条。断言点不在“异常被抛出”,而在“回滚之后每个表的内容”。这一条如果只测“返回 409”,完全可能掩盖“库存扣了、订单没插”的半执行状态。
五、条件扣减与并发:唯一约束和原子 UPDATE 是两种裁决
条件扣减:一条 SQL 完成裁决
库存扣减从来不是“先查再减”:
UPDATE stock
SET available = available - #{quantity}, version = version + 1
WHERE tenant = #{tenant} AND sku = #{sku} AND available >= #{quantity}WHERE available >= ? 与扣减是同一条语句,InnoDB 在行锁下保证原子性;影响行数 0 同时覆盖三种情形:租户/SKU 不存在、库存不足、请求被别人抢先扣完。服务把 0 行翻译成业务 409,不需要先读一遍再判断。这就是不超卖不变量在数据库层的落点。
并发扣减的不变量
16 个线程各带独立幂等键、并发扣同一 SKU(初始 8 件):
U79 concurrentDistinct=PASS threads=16 ok=8 rejected=8 stockAfter=0成功订单 8 笔、拒绝 8 笔、库存 0 不为负。断言的是不变量 初始库存 − 剩余 = 成功数,不是一个可能随调度变化的固定数字;如果换初始值,等式仍然成立,测试才是在验证不变量而不是验证运气。
并发重复:唯一约束裁决
幂等键的唯一约束建在 (tenant, idempotency_key) 上:同一租户内同键裁决重复,不同租户可以复用同一个字面 key(独立命名空间)。同一时刻的“并发重复”是另一类问题,裁决者不是条件 UPDATE 而是唯一约束。12 个线程带同一 Idempotency-Key 并发下单:
U79 concurrentDuplicate=PASS threads=12 ok200=9 conflict409=3 orders=1 effects=1
U79 duplicateSerial=PASS orders=1 stockDeduct=2三种结局同时存在:1 个胜者真正写入;若干晚到者在幂等预查里命中已提交记录,返回 200 + duplicated=true(这是重放,不是第二次效果);若干同时刻者与胜者撞唯一约束,被翻译成 409。不变量只看两件事:订单 1 条、库存只扣 1 件。ok200 + conflict409 = threads 保证没有请求落进 500 黑洞——如果不同时翻译“唯一冲突”这条路径,同时刻者会以 500 结束;本实验的断言把这种情况固定为失败。
同键异参(quantity 从 2 改成 3)是第三种:预查命中但 request_hash 不等:
U79 keyConflict409=PASSrequest_hash = SHA-256(sku|quantity) 固定请求语义,同键异参是客户端错误,409 冲突而不是“按新参数再下一单”。这里刻意不做“自动覆盖”:覆盖会让幂等键从“请求身份”退化成“最近一次请求”。
锁等待、应用超时与失败分类
还有一个容易被忽略的语义:请求不带 Idempotency-Key 时,服务端用 auto-<tenant>-<sku>-<quantity> 生成键,因此同一租户对同 SKU 同数量的两次“独立购买”会被当成重复重放。真正需要区分“两次购买”的接口必须由客户端提供高熵幂等键;服务端自动键只适合演示与“同参数重试即重放”的场景。
这是并发下的第四种失败面。持锁会话对同一行 SELECT ... FOR UPDATE 后,服务侧下单请求在 @Transactional(timeout=5) 下等待:
U79 lockTimeout503=PASS viaQueryTimeout elapsedMs=5023每条约 5 秒后语句被取消(@Transactional(timeout) 换算成每条语句的剩余 queryTimeout,不是单一定时器),Spring 翻译成 QueryTimeoutException,再映射为 503 lock_wait_timeout——不是 409 库存不足。这一点必须在异常映射上分清楚:库存不足是业务结论(不重试),锁超时是基础设施结论(可重试,但要服从幂等与总预算)。需要指出边界:服务器默认 innodb_lock_wait_timeout=50,本实验是应用侧超时先到;服务器侧锁等待超时分支未运行,归 U52 验证。
六、MyBatis 与 Spring 事务的接缝
这个案例只用了 MyBatis 的三个接缝,全部在真实 MySQL 上验证:
会话归属与提交所有权
接缝一:会话归属。 Mapper 是单例代理,真正的 SqlSession 由 SqlSessionTemplate 按当前线程的事务上下文解析。mybatis-spring 4.0.0 的拦截器(固定标签 mybatis-spring-4.0.0)是这样的:
private class SqlSessionInterceptor implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
var sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory, SqlSessionTemplate.this.executorType,
SqlSessionTemplate.this.exceptionTranslator);
try {
var result = method.invoke(sqlSession, args);
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
sqlSession.commit(true);
}
return result;
} catch (Throwable t) {
var unwrapped = unwrapThrowable(t);
if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
// release the connection to avoid a deadlock if the translator is no loaded. See issue #22
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
sqlSession = null;
Throwable translated = SqlSessionTemplate.this.exceptionTranslator
.translateExceptionIfPossible((PersistenceException) unwrapped);
if (translated != null) {
unwrapped = translated;
}
}
throw unwrapped;
} finally {
if (sqlSession != null) {
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
}
}
}
}四个分支正好解释本案例的行为:isSqlSessionTransactional 为真时不在这里 commit——物理提交归 Spring 事务管理器;非事务时强制 commit(true);PersistenceException 先经 exceptionTranslator 转成 Spring 的 DataAccessException 家族;closeSqlSession 只是归还使用权,不是物理断开。事务方法内四条 SQL 复用同一连接,这就是“库存 10→8 之后失败还能整体回滚”的底层前提:它们在同一物理事务里。
异常翻译链
接缝二:异常翻译。 MySQL 的唯一键冲突 SQLIntegrityConstraintViolationException 经 MyBatisExceptionTranslator 变成 DuplicateKeyException,而它是 DataIntegrityViolationException 的子类——服务捕获父类并翻译成 409 duplicate_request。运行时的翻译由 midFailureRollback 的 409 间接证明;另有一条只做类层次自检的断言(duplicateKeyIsIntegrityViolation=true,不执行转换器):
U79 exceptionTranslation=PASS duplicateKeyIsIntegrityViolation=true生成键与多参数边界
接缝三:SQL 与映射的显式 locator。 mybatis.mapper-locations=classpath:mapper/*.xml + @MapperScan,resultType=map 返回列名 key。首轮联调就踩到一个真实的 MyBatis 边界:给 5 个 @Param 的 insert 加 useGeneratedKeys keyProperty="id" 时,MyBatis 无法在多参数场景确定往哪个对象写主键(available parameters are [amount, param5, quantity, ...]),报 Could not determine which parameter to assign generated keys to。本案例不需要生成键回传,改用 findOrderByKey 回查;这也说明生成键的 keyProperty 语义在多参数 Mapper 上是有条件的,单参数/参数对象场景才自然成立。
MyBatis 一级/二级缓存、BATCH、Cursor、插件链的完整机制归 U42 与既有 MyBatis 权威页;本案例不启用二级缓存,也不把“本实验没开缓存”说成缓存不重要。
七、证明边界:模型、替身、真实组件分别是什么
| 类别 | 本案例有什么 | 不能推出什么 |
|---|---|---|
| 真实组件 | 真实 Servlet(随机端口 Tomcat 11.0.27)、真实 Security 7.0.8 资源服务器与 JWT 解码、真实 MyBatis 3.5.19 + mybatis-spring 4.0.0、真实 MySQL 8.4.7 专用库 | 生产 TLS、真实 IdP、多实例、连接池容量、网络分区 |
| 合成的部分 | HS256 对称密钥与合成 token(t1/t2)、合成 SKU/金额、测试自建一次性库 study_u79_* | 不代表生产密钥体系;不代表生产数据规模 |
| 未运行 | Kafka/outbox/死信(U80)、指标/追踪/负载/部署/迁移(U81)、MySQL MVCC/崩溃恢复/复制(U52)、服务器侧锁等待超时、MyBatis 缓存专项、死锁 loser 分类(1213) | 不能标为已通过 |
AC08 要求“正常、错误、重复/并发、故障恢复条件通过各自验收,模型/mock/真实组件的证明边界分别报告”。本文 19 个测试里,17 个走真实 Servlet 栈与真实 MySQL,2 个是独立短命上下文(配置失败启动、坏数据源探针——坏 URL 用例按设计不连真实 MySQL);没有 MockMvc 替身、没有内存数据库;受限之处逐条列在实验 README 与 evidence JSON 的 limits 字段里。教材完成不等于学习者掌握:下面的题目用来检查“能否在改变条件后仍给出正确裁决”,不是记忆题。
八、条件变化与架构师追问
- 如果库存与订单拆到两个库/两个服务:条件 UPDATE 与 INSERT 不再同一事务,“不超卖 + 不重复”需要预留(Try-Confirm-Cancel)或 outbox + 幂等消费,答案归跨资源事务与长流程与 U80。
- 如果同一 SKU 的热点行成为锁竞争瓶颈:分桶库存(
stock_bucket)把单行锁变成多行锁,代价是“总量视图”和“扣减失败重试”变复杂;不变量表达要改成“各桶之和”。 - 如果同一幂等键的并发度继续升高:预查 + 唯一约束兜底的成本是“败者事务仍扣了一次库存再回滚”,高冲突下可改为先插幂等记录(占位)再扣库存,代价是引入“占位未完成”的中间态,需要状态机与超时清理。两种顺序的取舍归 U80/U61。
- 如果租户规模到多 schema/分库:
tenant条件从 SQL 谓词升级为路由键,租户裁决同时出现在连接路由、SQL 与缓存键三处,任一遗漏都是越权或串数据。 - 如果失败发生在“提交后、响应前”:调用方看到超时,但订单已提交。此时幂等键是唯一可靠的恢复入口——用原键重放返回
duplicated=true的原订单;没有幂等键,就只能对账。这一半归 U80 的恢复实验。
闭卷验收
本页 19 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。