请求返回了 500,申请到底写进去了吗

供应商的采购申请从 HTTP 到 SQL,再到通知消费者;同一个业务动作可能跨越多个请求和事务。“见到异常日志”不等于“写入失败”,“看到 trace”也不等于“通知送达”。观测首先要能回答可核查的问题:这次请求的 ID 是什么、哪一个申请受影响、事务提交后数据库是什么、消息处理是否留下了独立结果。

本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。

现有工程 examples/javaee-enterprise/ 的 RequestIdFilter 为 /api/* 请求生成 UUID,放入 request attribute 和 X-Request-ID 响应头,在调用结束时清理 attribute。它没有把这个 ID 写进结构化日志、SQL span 或异步消息;请求 ID 只是入口证据,不能声称已有端到端追踪。JDK 21、Jakarta EE 11、Open Liberty 26.0.0.5、PostgreSQL 16.15 是当前隔离基线。可观测后端、采样率、追踪 SDK/agent 的版本、消息 broker 都没有冻结或实测。

过滤器注解覆盖 /servlet/* 与 /api/*,以及 REQUEST、FORWARD、ASYNC 三种分派;设置的是服务端新生成的 UUID,不是从调用者传入的 X-Request-ID 原样复用。请求下游可用同一个 servlet request attribute 读取,但当前 LabRequestsResource、ProcurementUseCases、JdbcRequestStore 都没有读取或持久化此 attribute。finally 中删除 attribute 只界定本次分派的生命周期;若后续新增独立的异步工作单元,必须明确把哪个身份、租户和 trace 上下文传给它,同时防止上下文泄漏到线程池的下一个任务。此处不能把“设置过 request attribute”误写成“所有日志自动携带 ID”。

三种记录承担不同问题

日志记录离散事实:请求进入、业务转移被拒、事务完成与消费者重试,字段至少包括环境、受限的租户标识、操作、请求 ID 和结果;不要把口令、请求原文或高敏采购明细直接输出。指标度量总体趋势:请求量、错误率、耗时直方图、连接池等待和消息积压;业务 ID、租户 ID、请求 ID 不应当作为指标标签,否则每次调用产生新时间序列。追踪将一个 HTTP 动作及其 DB、消息子操作组织为有因果关系的 span;若异步发布要关联下一段处理,必须显式传递并校验 trace 上下文,不能假设线程本地状态或原请求事务自动越过队列。

数据库耗时至少要区分获取连接、执行 SQL、等待锁和提交;一次 HTTP 总时长还包括序列化、网络和外部调用。对 ORM 扩展而言,抓取、N+1 与缓存 中的 4 对 1 是特定 Hibernate 统计、三行示例,不是此 JDBC 服务的 SQL 次数或缓存命中率。Schema 迁移与集成测试 中的新库测试也没有端到端 tracing 或吞吐值。若切换实现,需重新量测 SQL 和数据库终态。

拿当前采购实现举例,一次建单可以经过 LabRequestsResource.order()、ProcurementUseCases.order()、JdbcRequestStore.find()、insertOrder()、transition()。find() 先读申请再读明细,insertOrder() 可能先尝试 INSERT ... ON CONFLICT ... RETURNING id,冲突时才做按申请及租户查找;业务脚本并不会逐句打印这条路径。一次 HTTP 的 SQL 次数取决于走的是新建还是重试分支,不能单凭一个总时长反推 SQL 次数。恢复分析至少需要请求入口时间、数据库连接获取、各条 SQL 执行、事务提交/回滚时间四类可分开的测量点;耗时精度、采样与异常标签也应标明提供方,而不是把异常堆栈里出现 SQL 就算作完成了数据库 span。

指标使用有限集合的标签,例如模板化路由 /api/lab/requests/{id}/order、HTTP 结果类别和部署版本。若给每个 request_id、申请 ID 或租户 ID 都建立独立时序,即使只有一条失败请求,也可能增加新标签组合;尾延迟图反而会被大量稀疏序列污染。业务对象 ID 应在访问受控、脱敏的日志或业务账本中检索。若选择 trace 与日志关联,需要保证同一部署、同一采样决策下的 span 与日志字段可相互引用;抽样丢弃的 span 不构成“业务事件没有发生”的负证据。

现有入口能复核哪一段

按 examples/javaee-enterprise/README.md 构建、配置隔离 javaee_lab 数据库并启动本机 WAR 后,从仓库根目录执行;启动服务器进程前须设置 JAVAEE_DEMO_MODE=true,客户端后设不生效:

1
2
3
4
5
6
cd examples/javaee-enterprise
export JAVAEE_PORT=9085 JAVAEE_DEMO_MODE=true JAVAEE_LAB_USER=javaee_lab
printf 'Local lab database password: '; read -rs JAVAEE_LAB_PASSWORD; printf '\n'
export JAVAEE_LAB_PASSWORD
curl -fsSi --max-time 10 "http://127.0.0.1:${JAVAEE_PORT}/procurement/api/health"
bash scenarios/09-lab-procurement.sh

第一次请求应有 X-Request-ID 响应头和正文 ready;场景脚本分别检查建单前 409、正常建单 200、重复建单返回同一 ID 与数据库终态。用每次 HTTP 响应头可关联客户端请求,目前场景脚本不会保存所有响应头;要重建一次完整链路必须加记录器和日志字段,不能事后凭响应头编造服务器 span。

可再做一个独立、可复跑的“断链”负例:同一客户端连续两次送入自定 X-Request-ID,当前过滤器仍各生成自己的 UUID。命令会显示完整响应头,适合检查请求 ID 的生成,不要求先部署观测收集器:

1
2
3
4
5
6
set -o pipefail
for attempt in 1 2; do
curl -fsSi --max-time 10 -H 'X-Request-ID: client-marker' \
"http://127.0.0.1:${JAVAEE_PORT}/procurement/api/health" \
| sed -n '/^HTTP\//p; /^[Xx]-[Rr]equest-[Ii][Dd]:/p'
done

检查两次响应里的 ID 都非 client-marker,且不相同;如果找不到头,应先核对所请求路径是否匹配过滤器、运行包是否是当前工程、是否有代理改写了响应。只看到两个不同 UUID 并不能证明任何跨服务的 trace;它只是识别“用户提供的 header 不是被当前实现接纳的父上下文”。今后如引入 traceparent,也不能把任意 HTTP header 直接当作可信租户或业务身份。

数据库可以提供另一种有限视角。以下只读 SQL 抓取当前数据库活动会话的快照;它既没有采购 request ID 也没有 SQL 的历史持续时间,不能作为完整业务追踪;pg_stat_activity 的可见性还受数据库账号权限约束:

1
2
PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-v ON_ERROR_STOP=1 -c "SELECT pid,state,wait_event_type,wait_event FROM pg_stat_activity WHERE datname=current_database() ORDER BY pid"

例如某个请求在连接池排队,可能还没有拿到 PostgreSQL 会话;这条 SQL 看不到“池内等待”。某事务等待数据库行锁时,数据库侧才可能观察到相应等待事件。观测链断开的位置不同,要用客户端响应、容器计数器和数据库快照分别定位,不可把一份快照冒充事务已提交的证据。

失败路径仍用真实受管事务注入,而不是编造“遥测已丢”的记录:

1
2
# 只在隔离教学实例执行;脚本期望注入 HTTP 500 且数据库提交行数为 0
bash scenarios/18-lab-rollback.sh

HTTP 500 是有意制造的业务故障;脚本退出 0 表示其断言通过。归档 writing-plans/javaee-enterprise/verification/20261004T063400Z-pg16-jta-rollback/RUN.md 记录了当时一次 500 与数据库终态,但没有提供整条业务 trace、指标后端或导出故障证据;本篇未重跑。已知 ID 但找不到关联日志是观测断链,不是回滚失败的证明。

若要把这次失败改造成正式观测验收,应在响应、应用日志、数据库业务行和遥测接收端各取一次记录。响应记录 HTTP 500 与 request ID;服务日志记录处理失败的异常类别以及所用业务 ID,但不能打印数据库口令;另开连接查询注入租户无已提交申请;接收端记录是否确实接收到相关 span。四处的 ID 关系必须由实际传递链保证,不能事后根据相近时间戳硬配对。正常回放同理,至少比较 09-lab-procurement.sh 返回的申请/订单 ID 与数据库 purchase_order 行;如果遥测不见踪影而订单存在,应把“业务完成”和“导出失败”分别记账。

消息传播需单独设计:发布时把稳定的业务申请 ID/消息 ID 置于消息体或受控属性,把 trace 上下文放在明确命名的传播载体中;消费时由容器建立新的处理入口,提取上下文并记录重投次数、去重决策、最终确认或失败归档。业务 ID 用于查询幂等结果,trace ID 用于关联诊断,两者不能互换。在当前没有 broker 的条件下,这些都是实现合同,不能给出“已经打通通知 span”的截图或数字。若收集端停机,先观察应用是否仍按既定配置完成事务、是否因为导出队列满导致阻塞/丢弃,再恢复收集端复测;没有导出器的现阶段,谈不上真实的“停机—恢复”遥测实验。

一个完整记录还要能处理入口响应丢失的情况。客户端拿不到 X-Request-ID 时,不应凭时间范围在多条服务器日志中猜“这条就是赢家”;应在请求前保存可安全重用的业务申请 ID 或业务幂等键,再用授权的查询接口或数据库独立连接读回结果。当前创建请求尚没有稳定的客户端幂等键,演示脚本只在正常 201 响应中解析服务器分配的 ID;如果响应在 201 前丢失,客户端无法依当前 API 判断自己刚创建了哪条申请。把这个边界写入观测与协议的共同需求,比额外打印更多堆栈更有用。

日志留存也会影响判断。对于成功建单,HTTP 响应中 orderId 可以与 purchase_order.id 对照;对于回滚注入,申请 ID 可能只在未提交事务和数据库序列中出现,不应作为已存在采购申请对外展示。记录异常时要区分 RequestConflictException 转出的 409、RequestMissingException 转出的 404 与 IllegalStateException 引发的 500,避免把预期的状态拒绝混入系统错误率。直接把租户字段当作指标标签还可能泄露组织标识;在日志侧也应按访问权限和保存期限分级处理,而不是为了排障无限期保留请求原文。

导出失败实验至少需要两组相同的采购输入:一组收集端正常、另一组不可用,并给两组各自独立的租户和申请 ID。两组均记录 HTTP 状态、数据库订单数、导出失败或丢弃计数、恢复收集端后的新请求。若后一组在收集端恢复后才有 trace,不得补写成“故障期间实时到达”;若业务也失败,则进一步核对是否启用了同步导出或有界队列造成的背压。没有固定收集器配置和导出实现版本,这套比较仍是 NOT_RUN 的协议,不存在可引用的失败曲线。

为了让这个对照可复查,采样策略必须写到请求层。若只抽取 1% 的请求,某条采购请求缺少 span 可能完全符合配置,而不是导出失败;若只在错误时临时加采样,也会改变成功与失败组的可比性。应分别记录入口请求计数、进入采样器的数量、入队数量、导出尝试数量与收集端确认数量,再按同一观察窗核对差值。指标可能因进程重启而归零,日志也可能滚动清理,故“收集端无记录”只能在明确保存窗口、采样设置和发送端状态后解释。当前工程没有这些计数器,不能填上一组猜测值。

此外,正常采购后的恢复探针应与故障期间的失败申请分租户保存:一组观察原有订单是否仍为一张,另一组确认系统恢复后可以创建新订单。只核对新请求 200,无法证明旧请求没有重复提交;只查旧申请,也不能证明服务恢复提供新服务的能力。

若排障窗口跨越服务器重启,还要用实际部署 WAR 摘要和服务器启动时间切分日志:重启前产生的请求 ID 不能靠新进程的日志自动重现。同一采购申请可能对应多次 HTTP 请求,关联时以受控业务 ID 为线索,同时保留每次调用的不同请求 ID,避免把一次失败和后续重试误判成同一条 HTTP 链路。

真正的验收还需在已有请求/业务 ID 之外引入经过审查的跨进程上下文传播,固定采样和导出端,分别保存 HTTP、SQL、发布与消费的关联 ID。再将收集端置为不可用:隔离实例上比较同一业务操作的 HTTP 与数据库结果和导出缓冲/丢弃计数;若遥测失败影响业务,则应显式记录背压或同步导出的配置与错误,而不是预言它“一定不影响业务”。还要注入不传播上下文的消费者,与正确传播组对照;broker、SDK、导出失败、采样对照目前全为 NOT_RUN。记录字段与判据见 观测实验合同。

两道练习

练习一: 同一次订单的 X-Request-ID 在网关有记录,消费者日志没有该值,可以断言通知没有处理吗?

解: 不能。请求 ID 只随当前 HTTP 响应返回,消息传播尚未实现。先用持久业务 ID、消息 ID 和消费者去重账本确认是否处理,再检查上下文注入和提取是否成功;没有账本时结论应是未知,不能将观测缺失当成业务失败。

练习二: 团队把 requestId 加入 http_duration_seconds{requestId=...},希望精确定位 p99 慢请求。这会有什么代价?

解: 每次请求都产生新标签组合,时间序列数随流量增长。直方图只保留有界的路由、状态类别等标签以估计分布;需要定位具体慢请求时用带请求 ID 的日志或采样 trace,并对未采样请求保留独立的业务结果查询。p99 统计和单个请求定位不是同一张表的职责。

边界与官方资料

当前只有 HTTP 响应头、业务脚本和特定回滚归档;没有实际指标或 trace 的输出样本。规范定义 API/组件的边界,不保证某发行版自动生成端到端观测数据。采样造成“无 span”、导出失败造成“无数据”、业务事务失败造成“无提交”,需要各自独立证据。