trace 存到哪?用 Go 写一个能收、能存、能查的后端

三个接口:POST /api/traces 收、GET /api/traces/{id} 取、GET /api/traces 带过滤和游标分页地列。中间是 五道校验、一条参数化 SQL、一个幂等写事务,底下是开了 WAL 的 SQLite。

每一个设计点都对应一类线上事故:超大请求拖垮内存、拼接 SQL 被注入、客户端重试写出两份、并发写互相 BUSY、翻页翻出重复。每一类都用 go test 确定地复现一遍。

1接口设计

方法与路径做什么成功常见失败
POST /api/traces上传一条完整 trace201,result 是 created / unchanged / replaced401、413、415、400
GET /api/traces/{id}取一条,格式和上传的一样200400(id 格式不对)、404
GET /api/traces摘要列表:status、model、name、since/until 过滤,limit + cursor 分页200,带 next_cursor400(参数不合法)
GET /healthz、GET /readyz存活 / 就绪(就绪检查真的写一次库)200503

路由用 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五道校验:便宜的放前面

  1. 鉴权(配置了 token 时):Authorization: Bearer …,常量时间比较。401。
  2. Content-Type:mime.ParseMediaType 后必须是 application/json(带 charset 也行)。415。
  3. 体积上限:http.MaxBytesReader 包住 body,读超了返回 *http.MaxBytesError(Go 1.19 起有这个类型)。它不会自动回 413,要自己 errors.As 判断再回。
  4. 严格解析:json.Decoder 开 DisallowUnknownFields(拼错的字段名直接 400,而不是被悄悄丢掉)和 UseNumber(数字保留原文:80.0 不会变成整数 80 混过检查,超过 253 的整数不丢精度),再解码一次确认后面没有第二个 JSON 值。
  5. 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 请求不会都以为自己是第一个。

和第 5 周的取舍不同:第 5 周同键不同请求体回 422(拒绝),这里是覆盖(后到的赢)。覆盖适合"客户端补全后重发同一条 trace";代价是两个不同的运行撞了 trace_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 · DEFERRED1197–1199 / 120010–30读也有 26–28% BUSY
B WAL · 8 写连接 · busy=0 · DEFERRED1095–1102899–9921.29–1.74 ms
C WAL · 8 写连接 · busy=5s · DEFERRED1088–1113834–9781.45–1.81 ms
D WAL · 8 写连接 · busy=5s · IMMEDIATE01591–18130.69–0.92 ms
E WAL · 1 写连接 · busy=5s · IMMEDIATE(服务默认)01683–19040.72–0.86 ms
F rollback 日志 · 1 写连接 · busy=5s · IMMEDIATE01058–10709.88–18.71 ms
  1. 只开 WAL 不够(B):写事务先 SELECT 指纹再 INSERT,DEFERRED 事务从读升级到写时,别的连接已经在写,就返回 SQLITE_BUSY(错误码 5),或者快照已过期返回 BUSY_SNAPSHOT(517)。
  2. 加 busy_timeout 也不够(C ≈ B):SQLite 文档说,busy handler 可能导致死锁时不会被调用,直接返回 BUSY。读升级写正是这种情况。
  3. BEGIN IMMEDIATE(D、E):一开始就拿写锁,拿不到就按 busy_timeout 等,于是 0 失败。写本来就是串行的,8 条写连接(D)并不比 1 条(E)快。
  4. rollback 日志下读者要等写者(F):同样 0 失败,但读 p99 逐轮是 E 的 12.5、21.8、14.3 倍(另一次运行是 34–79 ms),波动很大。
实验顺带抓到一个真 Bug。A 配置第一次跑时,大量错误是 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。

预设
Content-Type 服务端 token 请求带的 token
超过上限时在第 3 关被拦下,后面的解析和校验都不会执行。
(还没发送)

数据库里的 traces 表

▶演示 2:同一个输入,拼接和参数化各查出什么

6 条 trace,模型名见下表。输入一个 model 过滤值,上面是字符串拼接得到的 SQL(绿色是字符串字面量,红色是"逃出"字面量、被当成 SQL 的部分,划线是被注释掉的部分),下面是参数化写法。拼接那一行的结果由页面里一个只认 = / AND / OR / 括号 / -- 注释、单独一个值当条件时按 SQLite 真值规则算(数字非 0 为真,字符串取开头能转成数字的部分)的小解析器算出来;几个预设的结果和本机 Python sqlite3(SQLite 3.51.2)跑真实查询的结果一致。

预设
model =

字符串拼接

参数化

▶演示 3:翻页时有人在写

23 条 trace(t01…t23,每 3 条共用一个开始时间),按开始时间倒序。左边 OFFSET 分页,右边 keyset(游标)分页,每次点"下一页"两边各取一页。翻页之间可以插入更新的 trace(排到最前面),或者删掉一条已经读过的。红色是重复读到的,读完后统计原有 23 条里漏了几条。

OFFSET:重复
OFFSET:原有的漏读
OFFSET:已读完?
keyset:重复
keyset:原有的漏读
keyset:已读完?
OFFSET:ORDER BY start DESC, id DESC LIMIT n OFFSET k
keyset:WHERE (start, id) < (游标) ORDER BY … LIMIT n

✎练习

题 1(幂等):客户端上传一条 trace,2 秒没收到响应,原样重发。服务端其实已经写进去了。按本章的实现,第二次请求得到什么?
trace_id 是天然的幂等键,规范化内容的指纹相同就不写库。A 会让客户端把一次成功的上传当成失败;B 结果对,但每次重试都要删了重写,还把"内容真的变了"和"只是重试"混在一起,replaced 这个信号就没用了。
题 2(WAL):已经开了 WAL、设了 busy_timeout=5000,8 条写连接并发执行"先 SELECT 再 INSERT"的 DEFERRED 事务,实测九成以上写入还是 SQLITE_BUSY。最直接的原因是?
SQLite 文档:busy handler 可能导致死锁时不会被调用。读升级写就是这种情况,等多久都没用(实测 busy=0 和 busy=5s 的失败数差不多)。改成 BEGIN IMMEDIATE,一开始就拿写锁,拿不到时才会按 busy_timeout 排队。
题 3(算):纳秒时间戳 1759300000000000000 在浏览器里 JSON.parse 成 double。这个量级上,相邻两个 double 相差多少纳秒?(提示:它在 260 和 261 之间,double 有 52 位尾数)
ns
260 ≈ 1.15×1018 ≤ 1.76×1018 < 261,间距是 260−52 = 28 = 256 ns(Python math.ulp 核对过)。显示毫秒级耗时没问题;但前端把解析后的数字再传回服务端,最低几位就变了,指纹也就变了。
题 4(分页):OFFSET 分页、每页 5 条、按开始时间倒序。读完第 1 页后,有 4 条更新的 trace 写进来。第 2 页(OFFSET 5)里有几条是第 1 页已经读过的?
条
新数据排在最前面,把旧数据整体往后挤 4 位。原来的第 2–5 条现在在位置 6–9,正好落进 OFFSET 5 这一页。TestKeysetVsOffsetWhileInserting 实测重复 4 条;keyset 用"上一页最后一条之后"定位,不受前面插入的影响。
题 5(注入):列表接口的过滤条件是可选的,WHERE 必须动态拼。下面哪种写法是对的?
A 依赖你记得每一种转义规则、每一个拼接点都没漏;C 是黑名单,既会误杀(模型名里可以有 --),也拦不全。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 跑并发写,再用变异测试确认每个测试真的能变红。

追问准备

  1. 为什么不用 OFFSET 分页?翻页期间有插入会重复,有删除会漏;而且 OFFSET 越大,数据库要先数过、再丢掉的行越多(笔者分析)。keyset 用 (start_ns, trace_id) < (?, ?),本机 EXPLAIN QUERY PLAN 显示走 traces_by_start 索引、没有额外排序。代价是不能直接跳到第 N 页。
  2. synchronous=NORMAL 安全吗?SQLite 文档:WAL + NORMAL 不会损坏数据库,应用崩溃也不丢事务,但断电或系统崩溃可能回滚最近提交的事务。trace 可以由客户端重发(幂等),这个取舍可以接受;账务数据不行。
  3. 为什么选 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。
  4. 两个副本共用一个 SQLite 文件可以吗?WAL 要求所有进程在同一台机器上(共享内存),不能放在网络文件系统上;而且写只有一个。要多副本就该换 Postgres 之类的服务端数据库。

常见错误说法

❌ "开了 WAL 就能并发写":WAL 让读写互不阻塞,写者同一时刻仍然只有一个。
❌ "设了 busy_timeout 就不会 BUSY":读事务升级写事务时,SQLite 不调用 busy handler。
❌ "MaxBytesReader 会自动回 413":它只返回错误,状态码要自己判断、自己写。
❌ "把单引号转义了就不怕注入":用参数绑定,不要自己转义。
❌ "tx.Commit() 报错了,defer 的 tx.Rollback() 会兜底":Commit 之后 Rollback 只返回 ErrTxDone,SQLite 那边的事务可能还开着。