先给结论:「该不该写库」这个问题本身是错的——它不取决于 ctx 取消了没有,而取决于「这批行是否已经完整收齐 + requestId 是否已经见过」。ctx 取消只决定「还能不能把结果送回去」,不决定「要不要落库」。 把「传输层取消」和「业务层是否完整」混在一起,才是你 handler 里三个分支乱、容易漏回滚的根因。
下面按你的三个问题给可落地规则。
【一、先拆掉「半份数据」这个伪命题】 client-streaming 的语义是:Recv 到 io.EOF(= 客户端 CloseSend)才算一条完整请求。中途 Recv 返回 Canceled/DeadlineExceeded,说明请求根本没发完,内存里那批「攒了一半的行」在业务上就不存在,不是「半份数据」,是「一份没提交完的草稿」。 判据非常干净:
- 收到 io.EOF → 请求完整,进入「校验+落库」流程;
- 收到 Canceled/其它 error → 请求不完整,直接丢弃草稿,不要落库,返回 UNIMPLEMENTED/UNAVAILABLE 让客户端知道「没发完」。 所以你 handler 里「已落库/未落库/落了一半」三种状态,其实只有两种合法终态:「完整→落库」和「不完整→丢弃」。不存在第三种——只要落库动作是单事务、且发生在拿到 io.EOF 之后,InnoDB 的原子性就保证它要么整批成功要么整批回滚,永远不会停在「落了一半」。你之所以看到「落了一半」,是因为把落库拆成了多事务,或把「写业务表」和「写去重表」分开了(见下,要合成一个事务)。
【二、幂等表怎么设计(这是你重复落库的真凶)】 关键原则:去重占位和业务写入必须在同一个事务里,否则「占位成功但业务写失败」或「业务写成功但占位没写」都会漏。
CREATE TABLE req_dedup (
request_id VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0=进行中 1=成功 2=失败
result_json MEDIUMTEXT,
client_tag VARCHAR(64), -- 可选:客户端实例/会话标识
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (request_id),
KEY idx_created (created_at)
);
落库流程(Go 伪代码):
func (s *svc) Import(stream proto.ImportClient) error {
// 1. 收齐所有行;中途出错=请求不完整,直接丢
var rows []Row
reqID := ""
for {
m, err := stream.Recv()
if err == io.EOF { break }
if err != nil { return discard(stream, err) } // 不完整:不落库
if m.GetRequestId() != "" { reqID = m.GetRequestId() }
rows = append(rows, m)
}
if reqID == "" { return status.Error(codes.InvalidArgument, "missing request_id") }
tx, _ := db.Begin()
// 2. 抢占式占位:靠主键唯一约束做 CAS,天然原子
res, err := tx.ExecContext(ctx,
`INSERT INTO req_dedup(request_id, status, created_at, updated_at)
VALUES(?, 0, NOW(3), NOW(3))`, reqID)
if err != nil {
// 1062 主键冲突 = 这个 requestId 已经处理过/正在处理
if isDupKey(err) { return replayExisting(stream, reqID) } // 见下
tx.Rollback(); return err
}
// 3. 同事务写业务表
if err := s.writeBusiness(tx, rows); err != nil {
tx.ExecContext(ctx, `UPDATE req_dedup SET status=2, updated_at=NOW(3) WHERE request_id=?`, reqID)
tx.Rollback()
return status.Error(codes.Aborted, "partial validation failed, nothing persisted")
}
// 4. 成功 + 记录可回放结果,一次 commit
tx.ExecContext(ctx, `UPDATE req_dedup SET status=1, result_json=?, updated_at=NOW(3) WHERE request_id=?`,
s.summarize(rows), reqID)
if err := tx.Commit(); err != nil { return err } // 未提交=没成功,客户端可重试
// 5. 结果回传:ctx 已取消也尽量 Send;发不出去无所谓,已落库
if err := stream.Send(&proto.ImportReply{...}); err != nil {
log.Warn("committed but send failed", "reqID", reqID) // 客户端会重试,重试会命中 replay
}
return nil
}
replayExisting:查 req_dedup,status=1 直接把 result_json 回放给客户端(这就是「客户端彻底重试」时不重复下单的关键——它拿到的还是第一次的结果);status=0 说明上一轮还在跑,返回 UNAVAILABLE 让它稍后再试;status=2 上一轮失败,允许它重新走完整流程(此时占位行要先删或复用,建议复用:把 status 置回 0 再走 writeBusiness)。
【三、TTL / 清理】
request_id 唯一键会无限堆积,按「客户端最长重试窗口 + 安全余量」定 TTL。经验值:TTL = 客户端最大重试时长 × 2,你日均 30 万次、重试窗口若为 10 分钟,TTL 设 1~2 小时足够;用 idx_created 定时 DELETE ... WHERE created_at < NOW(3) - INTERVAL 2 HOUR 分批删(LIMIT 5000 循环,别一把删)。不要用 MySQL 的 ON UPDATE CURRENT_TIMESTAMP 或应用内存 map 去重——重启就丢,等于没做。
【四、「客户端会重试」还是「彻底放弃」能不能区分?——不能,默认按「会重试」处理】 TCP/RPC 层给你的是「连接断了」,它不携带「客户端业务意图」。你无法从 Canceled 里区分「网络抖动马上重连」和「用户点了取消」。所以服务端唯一安全的假设是:只要我 commit 了,就默认客户端会重试,因此必须有幂等兜底(上面第二点);只要我还没 commit,就当它可能重试,保证重试能安全重放。换句话说:
- commit 前失败 → 幂等靠「占位行未成功」,重放安全;
- commit 后 send 失败 → 幂等靠「占位行已存在 + result 可回放」,重放安全; 两条路都收敛到「重试不会重复写」。你不需要、也不可能去猜客户端意图。
【五、量化收益与验收判据】
- 改造前:send 阶段断连 → 可能写半份脏数据(脏)+ 重试重复下单(重)。
- 改造后:
- 脏事务率 → 0(落库只在 io.EOF 之后、单事务内);
- 重复下单率 → 0(主键唯一键 CAS 抢占);
- P99 不受影响(去重是 1 次 PK 命中,亚毫秒级,30 万次/日摊薄可忽略);
- 可观测:
SELECT status, COUNT(*) FROM req_dedup GROUP BY status应看到 status=2(失败)占比 <0.1%,1062 冲突回放次数应等于客户端实际重试次数。 压测脚本:并发 50 个客户端,每个发同一request_id3 次、其中 1 次在 Send 汇总前 kill 连接,断言业务表该 request 只落 1 份、结果一致。
一句话收尾:把「传输取消」和「业务完整性」解耦,把「去重占位」和「业务写入」绑进同一个事务,用 request_id 主键做原子 CAS,ctx 取消就不再是写库的决策变量,只是「结果能不能送回去」的信号。
这篇幂等落库的设计我很认同——「传输取消 vs 业务完整性解耦 + 去重占位和业务写绑进同一事务」是正解,主键唯一键做 CAS 抢占也很干净。补充两个中间件侧的坑,都是我们踩过之后才补的:
-
status=0 占位行会被永久卡死。你的流程里,进程如果在「INSERT 占位成功 → 还没 commit 业务表」之间被 OOMKill(gRPC 服务很常见,流式请求内存累积大),占位行就停在 status=0。后面客户端重试会一直命中
isDupKey→replayExisting,而 replayExisting 看到 status=0 只会返回 UNAVAILABLE 让它"稍后再试"——但它"稍后再试"永远也等不到 status 变成 1,这是个死锁。建议加一个占位行的 TTL/心跳:status=0 的行updated_at超过 N 分钟(比如客户端最大重试窗口的一半)就允许被抢占重置(UPDATE ... SET status=0, updated_at=NOW(3) WHERE request_id=? AND status=0 AND updated_at < NOW(3)-INTERVAL 10 MINUTE,靠 affected_rows 判断是否抢到)。本质和消息队列的"消费超时重投"是一回事。 -
多实例部署下 1062 不是"幂等成功",是"竞争信号"。你单实例没问题,但 gRPC 服务多副本时,两个实例可能同时收到同一 request_id 的重试(LB 不保证粘性),双方都走到 INSERT,一个成功一个 1062。1062 那个实例的
replayExisting去查req_dedup,但赢家可能 commit 还没完成,查到 status=0 → 返回 UNAVAILABLE,客户端再重试又撞……偶发抖动。我们当时的解法是:1062 分支不要立刻 replay,先短退避(50~100ms)重查 2~3 次,给赢家 commit 留窗口;查到的 result_json 要和当前请求做一致性校验(比如行数/校验和),别盲目回放。
这两个坑的共同点:幂等的判据不能只看"占位行在不在",还要看"占位行处于哪个状态 + 状态多久没推进"。跟 Kafka 消费者判断"这条消息到底消费完没有"是一个道理——commit offset 之前崩了,光看 offset 会误判。