检测结果怎么落库——表结构、状态机与三类对账

调用第三方检测接口时,大多数人只关心「怎么拿到结果」。真正上线三个月后,让你半夜爬起来的是另一类问题:

  • 有 37 个任务卡在 running,钱扣了,结果没回来;
  • 有 5 笔任务 failed 了,但退款没到账;
  • 用户说「我明明只提交了一次,为什么扣了两次」。

这三个问题都不是接口的锅,是落库设计的问题。这篇讲清楚三件事:表怎么建、状态怎么流转、账怎么对。

一、表结构:把「钱」和「结果」分开

先看任务表。字段不多,但每个都有存在的理由:

sql 复制代码
CREATE TABLE tasks (
  id              VARCHAR(32)  NOT NULL PRIMARY KEY,      -- task_xxx,对外暴露
  user_id         BIGINT       NOT NULL,
  provider        VARCHAR(32)  NOT NULL,                  -- zhuque-text / zhuque-image
  status          VARCHAR(16)  NOT NULL,                  -- pending/running/succeeded/failed
  cost            DECIMAL(12,4) NOT NULL DEFAULT 0,       -- 提交时定价的快照
  quote           JSON         NULL,                      -- 计价明细:单价、单位数、最低消费
  result          JSON         NULL,                      -- 上游原始结果
  error_code      VARCHAR(64)  NULL,
  error_msg       VARCHAR(512) NULL,
  idempotency_key VARCHAR(128) NOT NULL,
  refunded_at     DATETIME(3)  NULL,
  created_at    DATETIME(3)  NOT NULL,
  started_at      DATETIME(3)  NULL,
  finished_at     DATETIME(3)  NULL,
  UNIQUE KEY uk_user_idem (user_id, idempotency_key),
  KEY idx_status_created (status, created_at),
  KEY idx_user_finished (user_id, finished_at)
);

(真实表里还有 upstream_at、attempts、api_key_id、latency_ms 等字段,这里只保留与对账相关的部分。usage_records 单独存计价快照,供毛利核算。)

几个关键决定:

1. cost 是快照,不是实时计算。 计价规则随时会调价,如果结果页每次都按当前单价重算,历史账单就会变。所以提交那一刻把 cost 和 quote 一起写死,之后所有退款、对账、报表都以它为准。

2. cost 用 DECIMAL,不用 FLOAT。 浮点数存钱是经典错误:0.1 + 0.2 != 0.3,几千笔之后账就差了几分钱,而这几分钱会让对账脚本永远报不平。DECIMAL(12,4) 对应四位小数,够用且精确。

3. idempotency_key 上加唯一索引,不是只做查询。 唯一索引是并发安全的最后一道防线。两个请求同时带同一个键进来,先查后插会有竞态;靠唯一索引冲突回查,才是原子的。

sql 复制代码
INSERT INTO tasks (id, user_id, ..., idempotency_key, status, cost, created_at)
VALUES (?, ?, ..., ?, 'pending', ?, NOW(3));
-- 捕获 1062 Duplicate entry,再 SELECT 一次返回已有任务

4. result 直接存 JSON,不拆表。 分段结果动辄几十上百条,拆成 segments 表会让「取一个任务」变成 N+1 查询,而业务上几乎没有「按段落检索」的需求。真要做分析,用离线的 JSON 解析任务跑批,不要为了假想的查询需求把写路径搞复杂。

二、状态机:只能用 CAS 流转

状态流转只有一条合法路径:

arduino 复制代码
pending ──> running ──> succeeded
   │           │
   └───────────┴────> failed

允许 pending 直接到 failed(入队失败、参数被上游拒绝),但不允许任何状态回退 ,尤其是 succeeded 回到 failed。

因为「失败」会触发退款。如果 worker 慢了一拍,在任务已经成功之后又把状态改成失败,就会退一笔本不该退的钱。这类 bug 在压测时几乎发现不了,只会在高并发下偶发。

所以每次状态变更都必须是带条件的更新:

sql 复制代码
-- 抢占任务:只有一个 worker 能从 pending 走到 running
UPDATE tasks
   SET status = 'running', started_at = NOW(3)
 WHERE id = ? AND status = 'pending';

-- 成功:只允许从 running 过来
UPDATE tasks
   SET status = 'succeeded', result = ?, cost = ?, finished_at = NOW(3)
 WHERE id = ? AND status = 'running';

-- 失败:同样只允许从 running 过来
UPDATE tasks
   SET status = 'failed', error_code = ?, error_msg = ?, finished_at = NOW(3)
 WHERE id = ? AND status = 'running';

判断依据是 RowsAffected():返回 0 说明状态已经变了,当前 worker 应该安静退出,而不是报错。这是最容易写错的地方------很多人把「更新 0 行」当成异常往上抛,结果日志里全是噪音。

退款也要做同样的保护:

sql 复制代码
UPDATE tasks
   SET refunded_at = NOW(3)
 WHERE id = ? AND status = 'failed' AND refunded_at IS NULL;

只有拿到 1 行,才去调资金接口。否则重复退款。

三、扣费与建任务必须在同一个事务里

如果先建任务再扣费,中间崩溃就会出现「有任务、没扣钱」的免费单;如果先扣费再建任务,崩溃则会出现「扣了钱、没任务」,用户会来投诉。

go 复制代码
err := withTx(ctx, db, func(tx *sqlx.Tx) error {
	if err := tasks.Create(ctx, tx, task); err != nil {
		return err   // 唯一索引冲突在这里暴露
	}
	_, err := billing.Charge(ctx, tx, userID, task.ID, quote)
	return err
})

扣费本身也要在 SQL 层做余额判断,不能「先查余额再更新」:

sql 复制代码
UPDATE wallets
   SET balance = balance - ?, total_consumed = total_consumed + ?
 WHERE user_id = ? AND balance >= ?;

RowsAffected() == 0 就是余额不足。行锁保证了并发下不会超扣,也不需要 SELECT ... FOR UPDATE 再判断。

四、退款:钱和状态要能对上

退款失败是最难查的一类问题,因为它跨了两个系统。解法是把「该退」和「已退」分开记:

sql 复制代码
CREATE TABLE refund_queue (
  id         BIGINT AUTO_INCREMENT PRIMARY KEY,
  task_id    VARCHAR(32) NOT NULL,
  user_id    BIGINT NOT NULL,
  amount     DECIMAL(12,4) NOT NULL,
  reason     VARCHAR(64) NOT NULL,
  status     VARCHAR(16) NOT NULL,   -- queued/done/failed
  attempts   INT NOT NULL DEFAULT 0,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  UNIQUE KEY uk_task (task_id)
);

任务失败时,在同一事务里写一条 refund_queue,状态 queued。真正调资金接口的动作由后台任务异步做,成功改 done,失败改 failed 并重试。

这样即使资金服务挂了,退款也不会丢------它变成了一条可重试的队列记录,而不是一个「最好能成功」的函数调用。

五、对账:三个查询,覆盖 95% 的问题

对账不需要复杂的框架,三条 SQL 就够了。

1. 卡住的任务(超过 10 分钟还是 pending / running):

sql 复制代码
SELECT id, status, created_at, TIMESTAMPDIFF(MINUTE, created_at, NOW()) AS minutes
  FROM tasks
 WHERE status IN ('pending', 'running')
   AND created_at < NOW() - INTERVAL 10 MINUTE
 ORDER BY created_at
 LIMIT 200;

pending 卡住通常是入队失败;running 卡住通常是 worker 崩溃或上游超时。两类都要处理,但动作不同:pending 重新入队,running 先查上游再决定。

2. 该退没退的:

sql 复制代码
SELECT t.id, t.cost, t.finished_at
  FROM tasks t
  LEFT JOIN refund_queue r ON r.task_id = t.id
 WHERE t.status = 'failed'
   AND t.finished_at < NOW() - INTERVAL 5 MINUTE
   AND r.id IS NULL;

3. 钱和任务对不上的(有任务但没扣费记录,或反之):

sql 复制代码
SELECT t.id, t.cost, l.amount
  FROM tasks t
  LEFT JOIN transactions l ON l.ref_type = 'task' AND l.ref_id = t.id
 WHERE t.status = 'succeeded'
   AND (l.id IS NULL OR l.amount <> -t.cost);

第三条是最有价值的。它不依赖任何业务假设,纯粹比较「应该扣多少」和「实际扣了多少」,能一次性抓出事务没生效、金额算错、重复扣费三类问题。

六、可观测性:从表里直接算,别另建一套

任务表的字段设计得好,监控指标可以直接用 SQL 算出来,不需要额外埋点:

指标 SQL 口径
提交量 COUNT(*) WHERE created_at >= 今天
成功率 succeeded / (succeeded + failed),按小时分组
P95 耗时 TIMESTAMPDIFF(MS, created_at, finished_at) 排序取分位
卡住任务数 status IN ('pending','running') AND created_at < NOW() - 10 分钟
退款积压 refund_queue WHERE status = 'queued'

P95 耗时不要用 AVG。检测耗时是长尾分布,平均值会被大量 1 秒的短任务拉低,看起来一切正常,实际 5% 的用户在等 20 秒。

还有一个容易被忽略的指标:上游耗时 vs 总耗时 。finished_at - started_at 是上游处理时间,started_at - created_at 是排队时间。两个分开看,才能判断瓶颈在对方还是在你的队列。

七、四个容易踩的细节

1. 时间字段用 DATETIME(3),不用 TIMESTAMP。 TIMESTAMP 会受时区影响,跨时区部署时 NOW() 的语义会变。DATETIME 存的是字面值,配合应用层统一用 UTC 写入,行为可预测。毫秒精度是必须的------秒级精度下,同一秒内的多个任务排序不稳定,排查问题时很难还原顺序。

2. JSON 字段别存超大结果。 MySQL 的 JSON 类型上限是 1GB,但实际几百 KB 就会拖慢查询。文本分段结果通常几 KB 到几十 KB,没问题;如果遇到超长文档(两万字),分段可能有几百条,建议只存摘要 + 分段前 N 条,完整结果放对象存储,表里存 URL。

3. 索引顺序要对上查询。 idx_status_submitted (status, created_at) 服务于「扫卡住任务」;idx_user_finished (user_id, finished_at) 服务于「用户查历史」。如果反过来建,两个查询都会走全表扫。判断方法很简单:把 EXPLAIN 的 key 列看一眼,是不是你预期的那个索引。

4. 状态字段用字符串,不用数字。 status = 2 在日志里毫无意义,status = 'succeeded' 一眼就懂。多占几个字节,换来的是排障时不用查枚举表。

八、小结

落库设计的核心不是「存下来」,而是让每一个异常状态都可见、可修、可重放:

  1. cost 和 quote 在提交时快照,历史账单不随调价变化;
  2. 钱用 DECIMAL,不用浮点;
  3. 幂等键靠唯一索引兜底,不靠先查后插;
  4. 状态流转全部 CAS,RowsAffected() == 0 是正常分支;
  5. 建任务和扣费同一个事务;
  6. 退款先入队再执行,保证「该退」不会丢;
  7. 三条对账 SQL 覆盖卡单、漏退、金额不符;
  8. 指标从表里算,不另建一套埋点。

做到这些,半夜被叫起来时你至少能回答三个问题:卡在哪、钱对不对、怎么补。这比「接口很快」重要得多。


示例基于文识云(sbnew.top)的异步检测任务模型,表结构与对账思路与具体服务商无关。

相关推荐
z06.1 小时前
Linux系统中MySQL的安装
linux·mysql
136096757231 小时前
LLM 当裁判可靠吗:位置偏见、长度偏见、自我偏好
后端
拖孩2 小时前
流量主门槛从 500 降到 100,我的小程序上线 18 小时够到了线
前端·后端·微信小程序
养军博客2 小时前
Spring Security 4.x / 5.x / 6.x 配置全对比:前后端不分离与前后端分离
java·后端·spring·springsecurity
wifi___2 小时前
SpringBoot自动装配原理
java·spring boot·后端
日日大王学编程2 小时前
AI 时代后端方向新人的未来规划与看法
人工智能·后端·python·ai·golang
Fox爱分享2 小时前
都问程序员双十一买什么,我的答案一定是这块专业编程显示器
后端·程序员·ai编程
Small-Young2 小时前
数据库乐观锁深度解析:MySQL、PostgreSQL 实战 + Spring Boot 集成指南
数据库·spring boot·mysql·postgresql·乐观锁·并发控制
CCblandy2 小时前
缓存淘汰实测:一次全表扫描把 LRU 命中率从 46% 打到 38%,W-TinyLFU 守住 50% 却贵 11 倍
后端·python·缓存·性能优化·架构