trace 存到哪?用 Go 写一个能收、能存、能查的后端
三个接口:POST /api/traces 收、GET /api/traces/{id} 取、GET /api/traces 带过滤和游标分页地列。中间是 五道校验、一条参数化 SQL、一个幂等写事务,底下是开了 WAL 的 SQLite。
每一个设计点都对应一类线上事故:超大请求拖垮内存、拼接 SQL 被注入、客户端重试写出两份、并发写互相 BUSY、翻页翻出重复。每一类都用 go test 确定地复现一遍。
1接口设计
| 方法与路径 | 做什么 | 成功 | 常见失败 |
|---|---|---|---|
POST /api/traces | 上传一条完整 trace | 201,result 是 created / unchanged / replaced | 401、413、415、400 |
GET /api/traces/{id} | 取一条,格式和上传的一样 | 200 | 400(id 格式不对)、404 |
GET /api/traces | 摘要列表:status、model、name、since/until 过滤,limit + cursor 分页 | 200,带 next_cursor | 400(参数不合法) |
GET /healthz、GET /readyz | 存活 / 就绪(就绪检查真的写一次库) | 200 | 503 |
路由用 Go 1.22 起 net/http.ServeMux 的新模式:"POST /api/traces" 带方法,"GET /api/traces/{id}" 带通配符,handler 里用 r.PathValue("id") 取值。路径对上、方法不对时,ServeMux 自己回 405 并带 Allow 头(实测 DELETE 一条 trace 得到 Allow: GET, HEAD:注册 GET 会同时匹配 HEAD)。
数据格式是第 9 周三讲共用的约定(OTel 风格:trace_id / span_id / parent_span_id / kind / 纳秒时间 / status / 扁平 attributes),上一讲的查看器直接读 GET 的输出,下一讲用 /readyz 和环境变量部署。
2五道校验:便宜的放前面
- 鉴权(配置了 token 时):
Authorization: Bearer …,常量时间比较。401。 - Content-Type:
mime.ParseMediaType后必须是application/json(带 charset 也行)。415。 - 体积上限:
http.MaxBytesReader包住 body,读超了返回*http.MaxBytesError(Go 1.19 起有这个类型)。它不会自动回 413,要自己errors.As判断再回。 - 严格解析:
json.Decoder开DisallowUnknownFields(拼错的字段名直接 400,而不是被悄悄丢掉)和UseNumber(数字保留原文:80.0不会变成整数 80 混过检查,超过 253 的整数不丢精度),再解码一次确认后面没有第二个 JSON 值。 - schema 校验:id 格式、恰好一个根、parent 都存在且无环、kind / status 枚举、时间先后、属性只能是标量、
gen_ai.usage.*必须是非负整数。错误消息带字段路径,比如spans[1].end_time_unix_nano。
io.ReadAll 再判断长度,内存已经花出去了。还有一个细节:合法 JSON 后面跟一大串空白,第一次 Decode 不会超限,第二次(检查尾部数据)才读到上限,所以"检查尾部"这一步也要处理 MaxBytesError,测试里专门有这个用例。3参数化 SQL:trace 里装的是模型输出
第 8 周「找 Bug 的测试 Agent,会踩到 OWASP LLM Top 10 的哪几条?」讲过 LLM10:2026 Improper Output Handling(2025 版编号是 LLM05)。它的预防措施第 5 条原文是 "Use parameterized queries or prepared statements for all database operations involving LLM output."。trace 的 attributes 里是模型名、工具参数、模型生成的文字,正是 LLM 输出。
// 错误:model = "x' OR '1'='1" 时条件恒真,返回全部 trace
q := "... AND s.model = '" + model + "')"
// 正确:? 占位,驱动用 sqlite3_bind_text 绑定,值永远不会被当成 SQL 解析
where = append(where, "EXISTS (SELECT 1 FROM spans s WHERE s.trace_id = traces.trace_id AND s.model = ?)")
args = append(args, f.Model)
列表的过滤条件是可选的,WHERE 子句得动态拼。规则是:拼的只能是代码里写死的 SQL 片段,用户给的值一律走 ?。游标也一样:它是 base64 编码的"开始时间:trace_id",解码后先校验格式,再作为参数绑定。
4幂等写入:同一个 trace_id 发两次
第 5 周「超时了,再发一次安全吗?」用 Idempotency-Key 头做幂等。这里不需要额外的键:trace_id 本身就是天然的幂等键,客户端重试时它不会变。三讲共用的约定写的是"同一 trace_id 重复上传时整条覆盖",实现上再加一步指纹比较:
| 情况 | 做什么 | 响应 |
|---|---|---|
| 没见过这个 trace_id | 写入 | 201,created |
| 见过,规范化后的内容指纹相同 | 什么都不写 | 201,unchanged |
| 见过,内容不同 | 同一个事务里删旧、写新 | 201,replaced |
指纹是规范化 JSON(span 按开始时间和 span_id 排序,map 键排序)的 SHA-256,客户端换了 span 顺序也认得出是同一份。"查指纹 → 删 → 写"在一个 BEGIN IMMEDIATE 事务里,并发的两个同 id 请求不会都以为自己是第一个。
result,replaced 多了值得报警。代价之二:旧版本的请求如果因为网络延迟晚到,会覆盖已经写入的新版本(丢失更新);需要时可以带版本号或 end_time,只接受更新的那份。5WAL 与并发写:BUSY 从哪来
SQLite 官方文档:WAL 模式下读者不阻塞写者、写者不阻塞读者,但 WAL 文件只有一个,同一时刻只能有一个写者。本机实测(cmd/walbench,合成负载:8 个 goroutine 共写 1200 条 trace,4 个 goroutine 同时翻列表,每种配置 3 轮,Apple M1 Pro):
| 配置 | 写失败 | 写/秒 | 读 p99 |
|---|---|---|---|
| A rollback 日志 · 8 写连接 · busy=0 · DEFERRED | 1197–1199 / 1200 | 10–30 | 读也有 26–28% BUSY |
| B WAL · 8 写连接 · busy=0 · DEFERRED | 1095–1102 | 899–992 | 1.29–1.74 ms |
| C WAL · 8 写连接 · busy=5s · DEFERRED | 1088–1113 | 834–978 | 1.45–1.81 ms |
| D WAL · 8 写连接 · busy=5s · IMMEDIATE | 0 | 1591–1813 | 0.69–0.92 ms |
| E WAL · 1 写连接 · busy=5s · IMMEDIATE(服务默认) | 0 | 1683–1904 | 0.72–0.86 ms |
| F rollback 日志 · 1 写连接 · busy=5s · IMMEDIATE | 0 | 1058–1070 | 9.88–18.71 ms |
- 只开 WAL 不够(B):写事务先 SELECT 指纹再 INSERT,DEFERRED 事务从读升级到写时,别的连接已经在写,就返回 SQLITE_BUSY(错误码 5),或者快照已过期返回 BUSY_SNAPSHOT(517)。
- 加 busy_timeout 也不够(C ≈ B):SQLite 文档说,busy handler 可能导致死锁时不会被调用,直接返回 BUSY。读升级写正是这种情况。
- BEGIN IMMEDIATE(D、E):一开始就拿写锁,拿不到就按 busy_timeout 等,于是 0 失败。写本来就是串行的,8 条写连接(D)并不比 1 条(E)快。
- rollback 日志下读者要等写者(F):同样 0 失败,但读 p99 逐轮是 E 的 12.5、21.8、14.3 倍(另一次运行是 34–79 ms),波动很大。
cannot start a transaction within a transaction。原因:rollback 日志下 COMMIT 可能返回 BUSY,SQLite 文档说此时事务仍然是活动的;而 database/sql 的 Tx.Commit 不管成败都把事务标成结束,之后 Rollback 只返回 ErrTxDone,不会真的发 ROLLBACK。连接带着没结束的事务回到池里,污染下一个请求。修法:在 *sql.Conn 上手写 BEGIN / COMMIT,失败就补 ROLLBACK,ROLLBACK 也失败就让连接池丢掉这条连接。先写了一个确定性的测试让它变红,再修到变绿。▶演示 1:一个请求要过几道关
浏览器里用 JS 重写了服务端同样的校验和幂等逻辑(规则、顺序、状态码都和 Go 版一致;错误文案略有简化。少数边界(字段名大小写、null、指数写法)与 Go 不同),不连真实服务。选一个预设或者直接改请求体,点"发送"。"数据库"是页面里的一个 Map,刷新就清空。指纹用 FNV-1a(64 位)代替 Go 版的 SHA-256,只用来判断两次请求内容是否一样。体积上限这里按请求体总字节数判断;Go 版是边读边数,所以一个很大但开头就不是 JSON 的请求,在 Go 里可能先报 400 而不是 413。
数据库里的 traces 表
▶演示 2:同一个输入,拼接和参数化各查出什么
6 条 trace,模型名见下表。输入一个 model 过滤值,上面是字符串拼接得到的 SQL(绿色是字符串字面量,红色是"逃出"字面量、被当成 SQL 的部分,划线是被注释掉的部分),下面是参数化写法。拼接那一行的结果由页面里一个只认 = / AND / OR / 括号 / -- 注释、单独一个值当条件时按 SQLite 真值规则算(数字非 0 为真,字符串取开头能转成数字的部分)的小解析器算出来;几个预设的结果和本机 Python sqlite3(SQLite 3.51.2)跑真实查询的结果一致。
字符串拼接
参数化
▶演示 3:翻页时有人在写
23 条 trace(t01…t23,每 3 条共用一个开始时间),按开始时间倒序。左边 OFFSET 分页,右边 keyset(游标)分页,每次点"下一页"两边各取一页。翻页之间可以插入更新的 trace(排到最前面),或者删掉一条已经读过的。红色是重复读到的,读完后统计原有 23 条里漏了几条。
ORDER BY start DESC, id DESC LIMIT n OFFSET kWHERE (start, id) < (游标) ORDER BY … LIMIT n✎练习
replaced 这个信号就没用了。busy_timeout=5000,8 条写连接并发执行"先 SELECT 再 INSERT"的 DEFERRED 事务,实测九成以上写入还是 SQLITE_BUSY。最直接的原因是?JSON.parse 成 double。这个量级上,相邻两个 double 相差多少纳秒?(提示:它在 260 和 261 之间,double 有 52 位尾数)math.ulp 核对过)。显示毫秒级耗时没问题;但前端把解析后的数字再传回服务端,最低几位就变了,指纹也就变了。TestKeysetVsOffsetWhileInserting 实测重复 4 条;keyset 用"上一页最后一条之后"定位,不受前面插入的影响。--),也拦不全。B 让值根本不进 SQL 文本,OWASP LLM10:2026(2025 版的 LLM05)的预防措施写的就是参数化查询或预编译语句。6面试要点
一句话讲清楚
一个 trace 后端就三个接口,难点在边界:请求先过鉴权、Content-Type、MaxBytesReader 体积上限、严格 JSON 解析、schema 校验五道关;SQL 一律参数化,动态 WHERE 只拼写死的片段;trace_id 当天然幂等键,指纹相同不写、不同就在一个 BEGIN IMMEDIATE 事务里整条替换;SQLite 开 WAL 让读写互不阻塞,但写仍然只有一个,所以用单写连接 + IMMEDIATE,避免读升级写时 busy_timeout 救不了的 BUSY。测试用 httptest 表驱动覆盖每个状态码,-race 跑并发写,再用变异测试确认每个测试真的能变红。
追问准备
- 为什么不用 OFFSET 分页?翻页期间有插入会重复,有删除会漏;而且 OFFSET 越大,数据库要先数过、再丢掉的行越多(笔者分析)。keyset 用
(start_ns, trace_id) < (?, ?),本机EXPLAIN QUERY PLAN显示走traces_by_start索引、没有额外排序。代价是不能直接跳到第 N 页。 - synchronous=NORMAL 安全吗?SQLite 文档:WAL + NORMAL 不会损坏数据库,应用崩溃也不丢事务,但断电或系统崩溃可能回滚最近提交的事务。trace 可以由客户端重发(幂等),这个取舍可以接受;账务数据不行。
- 为什么选 modernc.org/sqlite?纯 Go、不需要 CGO,
CGO_ENABLED=0就能交叉编译成静态二进制,下一讲的极简镜像能直接跑。代价:本机 Go 1.24 能用的最新版是 v1.46.1(v1.46.2 起要求 Go 1.25),本文用的 v1.45.0 和 v1.46.1 都内置 SQLite 3.51.2,落在官方说的 WAL-reset bug 可能存在的范围(3.7.0–3.51.2)里;内置 3.51.3 的 v1.46.2 要 Go 1.25。升到 v1.46.1 没有安全收益,所以停在已经跑完全部测试的 v1.45.0。mattn/go-sqlite3 v1.14.52 内置 3.53.4、只要 Go 1.21,但要 CGO 和 gcc。 - 两个副本共用一个 SQLite 文件可以吗?WAL 要求所有进程在同一台机器上(共享内存),不能放在网络文件系统上;而且写只有一个。要多副本就该换 Postgres 之类的服务端数据库。
常见错误说法
❌ "设了 busy_timeout 就不会 BUSY":读事务升级写事务时,SQLite 不调用 busy handler。
❌ "
MaxBytesReader 会自动回 413":它只返回错误,状态码要自己判断、自己写。❌ "把单引号转义了就不怕注入":用参数绑定,不要自己转义。
❌ "
tx.Commit() 报错了,defer 的 tx.Rollback() 会兜底":Commit 之后 Rollback 只返回 ErrTxDone,SQLite 那边的事务可能还开着。