调用第三方检测接口时,大多数人只关心「怎么拿到结果」。真正上线三个月后,让你半夜爬起来的是另一类问题:
- 有 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' 一眼就懂。多占几个字节,换来的是排障时不用查枚举表。
八、小结
落库设计的核心不是「存下来」,而是让每一个异常状态都可见、可修、可重放:
cost和quote在提交时快照,历史账单不随调价变化;- 钱用
DECIMAL,不用浮点; - 幂等键靠唯一索引兜底,不靠先查后插;
- 状态流转全部 CAS,
RowsAffected() == 0是正常分支; - 建任务和扣费同一个事务;
- 退款先入队再执行,保证「该退」不会丢;
- 三条对账 SQL 覆盖卡单、漏退、金额不符;
- 指标从表里算,不另建一套埋点。
做到这些,半夜被叫起来时你至少能回答三个问题:卡在哪、钱对不对、怎么补。这比「接口很快」重要得多。
示例基于文识云(sbnew.top)的异步检测任务模型,表结构与对账思路与具体服务商无关。