在支付回调、订单流转、第三方对接这类业务里,一个完整动作往往要经历好几次接口调用才算是真正结束。这些请求分散在不同的时间点、不同的服务节点上,如果只是依赖零散的应用日志,事后排查问题时很难把它们拼成一条完整的线索。要解决这个痛点,就需要在设计层面做两件事:一是把每次请求的关键信息持久化到可靠的存储介质里,二是提供一个机制把这些记录按逻辑顺序串成轨迹,随时可以查询和还原。下面从数据结构、存储选型、串联机制和轨迹生成四个方面展开说明。

一、先想清楚要记录哪些请求信息
持久化的第一步不是急着建表,而是明确一次请求到底有哪些信息值得留下来。一般来说,可以分为四类:第一类是标识信息,包括请求唯一ID、本次业务流程的traceId、会话ID、用户ID;第二类是请求本体,包括请求方法、完整URL、请求头中的关键字段、请求参数;第三类是响应信息,包括状态码、响应体摘要、耗时毫秒数;第四类是上下文信息,包括时间戳、来源IP、服务节点、异常堆栈。这四类信息合在一起,才能在事后完整还原一次调用的全貌。
需要特别注意的是traceId的设计。多次请求之所以能串成轨迹,靠的就是同一个业务流程内的所有请求共享同一个traceId。这个ID通常在流程的入口处生成,比如网关或者第一个被调用的服务,然后通过请求头一路传递下去。下面是一个简单的传递示例:
// 在入口处生成或获取 traceId
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null || traceId.isEmpty()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
// 放入请求属性,供后续记录使用
request.setAttribute("traceId", traceId);
// 调用下游服务时透传
HttpHeaders headers = new HttpHeaders();
headers.set("X-Trace-Id", traceId);
这样做的好处是显而易见的:无论后续经历多少次请求,只要traceId不变,查询时用这一个字段就能把所有记录一次性捞出来。反过来,如果每次请求都独立生成ID,那么数据再多也只是散落的点,无法形成线。
二、存储方案选型:表结构与时序数据的取舍
确定要存什么之后,接下来是选存储介质。最常见的做法是关系型数据库建表,优点是查询灵活、事务支持好,适合请求量中等、需要频繁按业务维度检索的场景。一张典型的请求记录表可以这样设计:
CREATE TABLE request_trace_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
trace_id VARCHAR(64) NOT NULL,
span_id VARCHAR(64) DEFAULT NULL COMMENT '单次请求ID',
parent_span_id VARCHAR(64) DEFAULT NULL COMMENT '父级请求ID',
method VARCHAR(16) NOT NULL,
url VARCHAR(512) NOT NULL,
request_body TEXT,
response_body TEXT,
status_code INT,
cost_ms INT,
client_ip VARCHAR(64),
server_node VARCHAR(128),
success TINYINT DEFAULT 1,
error_msg TEXT,
created_at DATETIME(3) NOT NULL,
INDEX idx_trace_id (trace_id),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里有几个细节值得推敲。span_id用来标识单次请求,parent_span_id用来记录调用层级,有了这两个字段,轨迹不仅可以按时间排,还能还原出调用树的结构。created_at使用DATETIME(3)保留毫秒精度,因为同一个轨迹内的两次请求可能只相差几十毫秒,秒级精度会导致排序错乱。request_body和response_body建议做截断处理,只保留关键部分,否则大报文会迅速撑爆表空间。
如果请求量达到每秒上万级别,关系型数据库的写入压力就会显现。这时可以考虑两条路:一是异步批量写入,请求处理线程只往内存队列里丢数据,由后台线程批量落库,牺牲一点点实时性换吞吐量;二是换用时序型存储或者Elasticsearch,它们对追加写入场景做了专门优化,按traceId聚合查询的效率也更高。日志文件方案成本最低,配合ELK也能检索,但缺点是数据一致性依赖日志采集链路,中途丢一条就断一环,对强可靠性要求的场景不太合适。
三、用拦截器统一采集,避免侵入业务代码
采集逻辑不应该散落在各个业务方法里,否则既容易遗漏又难以维护。比较优雅的做法是写一个全局拦截器或中间件,在请求进入和离开时分别打点,业务代码完全不感知。以Spring为例,核心实现如下:
@Component
public class TraceInterceptor implements HandlerInterceptor {
@Autowired
private TraceLogService traceLogService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
request.setAttribute("startTime", System.currentTimeMillis());
// 解析并绑定 traceId
String traceId = TraceContext.getOrCreate(request);
TraceContext.bind(traceId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
long cost = System.currentTimeMillis()
- (Long) request.getAttribute("startTime");
TraceLog log = new TraceLog();
log.setTraceId(TraceContext.current());
log.setSpanId(IdGenerator.next());
log.setMethod(request.getMethod());
log.setUrl(request.getRequestURI());
log.setStatusCode(response.getStatus());
log.setCostMs((int) cost);
log.setCreatedAt(new Date());
if (ex != null) {
log.setSuccess(0);
log.setErrorMsg(truncate(ex.getMessage(), 2000));
}
// 异步落库,不阻塞主流程
traceLogService.saveAsync(log);
TraceContext.unbind();
}
}
这段代码里有三个要点。第一,afterCompletion方法即使发生异常也会执行,保证了失败请求同样被记录,这一点非常关键,排查问题时失败记录往往比成功记录更有价值。第二,落库动作通过saveAsync异步完成,采集逻辑不会增加请求的响应耗时。第三,TraceContext基于ThreadLocal实现,同一线程内任何地方都能拿到当前traceId,配合线程池使用时要记得在任务提交时传递上下文,否则异步任务里会丢失追踪标识。
四、轨迹生成:从一堆记录到一条完整链路
有了前面打下的基础,生成轨迹就变成了一个纯查询和组装的过程。基本思路是按traceId查出所有记录,再按创建时间排序,组装成有序的轨迹列表返回给前端展示。查询接口可以写成这样:
public List<TraceNode> buildTrace(String traceId) {
List<TraceLog> logs = traceLogMapper.selectByTraceId(traceId);
// 按时间升序排列,形成轨迹主线
logs.sort(Comparator.comparing(TraceLog::getCreatedAt));
List<TraceNode> nodes = new ArrayList<>();
for (TraceLog log : logs) {
TraceNode node = new TraceNode();
node.setSpanId(log.getSpanId());
node.setParentSpanId(log.getParentSpanId());
node.setUrl(log.getUrl());
node.setStatusCode(log.getStatusCode());
node.setCostMs(log.getCostMs());
node.setTimestamp(log.getCreatedAt());
nodes.add(node);
}
return nodes;
}
前端拿到这个有序列表后,可以用时间轴的形式展示每一次请求的时序、耗时和状态,再借助parent_span_id渲染出层级缩进,一条直观的调用链路就呈现出来了。如果想让轨迹更完整,还可以在关键节点补充业务快照,比如订单状态变更前后的值、第三方返回的原始报文,这样即使过了几个月再回头看,当时发生了什么依然一目了然。
最后提一点数据治理的建议。轨迹数据是典型的写多读少数据,建议按created_at做分区或定期归档,比如只保留最近90天的明细,更早的数据聚合后转存。同时给trace_id字段建索引时要注意,高基数索引对写入有一定影响,量大的场景可以考虑按天分表,把单表数据量控制在一个合理范围内,查询性能和存储成本都能得到兼顾。这样一套方案落地之后,无论是线上排障还是业务审计,多次请求的全过程都可以随时回放,问题定位的效率会有明显提升。