当前位置:首页 > 文章列表 > Golang > Go问答 > Go SQL 事务遇到死锁怎样设计有限重试

Go SQL 事务遇到死锁怎样设计有限重试

来源:17golang原创 2026-10-14 23:21:37 0浏览 收藏

Go 的 database/sql 没有替应用自动判断“这个错误是否值得重试”。比较稳妥的做法是把一次完整事务当成一个不可拆分的工作单元:从 BeginTx 开始,所有读取和写入都使用同一个 *sql.Tx,遇到数据库明确标识的死锁后回滚整个事务,等待一小段有上限的时间,再从头开始;普通业务错误、约束错误和未知的提交错误都直接返回。

官方入口:https://pkg.go.dev/database/sql。本文只讨论事务重试边界,不把“所有数据库错误都再试一次”当成方案。

先把可重试错误和业务错误分开

我在排查这类问题时,最先确认的不是重试次数,而是错误分类。死锁的本质是多个事务互相等待,数据库为了打破等待环会主动回滚其中一个事务;这和唯一键冲突、参数错误、权限不足不是一回事。后几类错误即使再执行,也不会因为等待一会儿就自然变好。

不同驱动暴露错误信息的方式不同,因此重试器不应该在通用层里用字符串包含“deadlock”来猜。MySQL InnoDB 常见的死锁错误是 1213;PostgreSQL 使用 SQLSTATE 40P01 表示 deadlock_detected,而 40001 表示 serialization_failure。这些编号要由当前驱动的错误类型适配,而不是硬编码成对所有数据库都成立的规则。

MySQL 官方建议,事务因死锁回滚后要重新发起整个事务,并尽量缩短事务、统一访问顺序。PostgreSQL 文档也强调,发生序列化失败时要从头重试包括决定执行哪些 SQL 和使用哪些值的完整事务逻辑。参考地址:https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlocks-handling.html、https://www.postgresql.org/docs/current/mvcc-serialization-failure-handling.html。

Go database/sql 完整事务单元、错误分类、回滚和有限重试之间的职责关系
图1:完整事务单元与有限重试的职责关系说明图。它展示设计边界,不是浏览器、终端或数据库运行截图。

重试的最小边界是整个事务

死锁发生后,当前事务已经不再是可以安全继续使用的工作空间。只重发刚刚失败的那条 SQL,可能遗漏前面已经读取的值,也可能把一半旧状态和一半新状态拼在一起。正确的边界是“重新开始一次业务操作”,而不是“重新执行上一条语句”。

下面的包装器把错误判断函数作为参数传入。事务控制层只负责生命周期;MySQL、PostgreSQL 或其它驱动的错误码映射放在适配层,换数据库时不会把驱动细节散落在所有业务函数里。

package txretry

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
    "time"
)

// RetryDecider 只应把已确认会回滚事务的瞬时错误判为可重试。
type RetryDecider func(error) bool

// Run 在一次尝试中完成整个业务事务,失败后才重新 BeginTx。
func Run(ctx context.Context, db *sql.DB, maxAttempts int, isRetryable RetryDecider, work func(context.Context, *sql.Tx) error) error {
    if maxAttempts 

这个例子有意把 work 放在循环内部。它必须重新读取本次事务需要的状态,重新计算要写入的值,不能把上一轮的 *sql.Tx、行锁对象或依赖旧快照的结果带进下一轮。

退避要有限,也要能被取消

重试不是越快越好。多个请求同时遇到死锁时,如果所有请求立即再次抢锁,可能形成新一轮碰撞。短暂退避可以给另一方事务释放锁的机会,但退避必须有三个出口:尝试次数有上限,单次等待有上限,请求的 context 取消时立即退出。

示例用平方增长的 25 毫秒作为简单退避:第 2 次前等待 25 毫秒,第 3 次前等待 100 毫秒,第 4 次前等待 225 毫秒。生产环境可以加入受控抖动,但要保留最大等待值,并把实际尝试次数、数据库错误码和总耗时记录到指标或日志中。不要把退避写成固定的无限 for,也不要在没有检查 ctx.Done() 的情况下直接 time.Sleep。

maxAttempts 不是“保证成功”的承诺。如果三次尝试都遇到明确死锁,最终仍应把错误交给上层,由上层决定返回冲突、排队、降级或触发人工处理。有限重试的价值是吸收偶发并发碰撞,而不是掩盖持续存在的锁顺序问题。

统一加锁顺序,别只依赖重试

我更愿意把重试看成最后一道缓冲,而不是死锁修复本身。两个事务如果一个先锁“账户行”再锁“库存行”,另一个先锁“库存行”再锁“账户行”,就可能形成等待环。把所有相关业务路径约束为相同顺序,通常比把重试次数从 3 次改成 30 次更有效。

锁顺序应该落到代码结构里,而不是只写在文档里。例如转账和库存扣减都涉及账户与库存时,可以让两类操作先按稳定的主键顺序锁账户,再锁库存;涉及多行同类资源时,先排序 ID 后按同一方向访问。索引是否能缩小扫描范围、事务是否夹带慢查询和外部调用,也会影响锁持有时间。

两个 SQL 事务相反加锁形成等待环与统一顺序消除等待环的静态关系
图2:相反加锁顺序会形成等待环,统一访问顺序可以降低死锁概率。它是概念结构图,不代表某个真实数据库现场。

错误适配器只认明确证据

通用重试函数需要的不是“看起来像临时错误”,而是一个可审查的适配器。适配器可以按当前数据库驱动的原生错误类型读取错误码:例如 MySQL 驱动的错误号 1213,或者 PostgreSQL 驱动错误的 SQLSTATE 40P01;是否把 40001 一并重试,则要结合应用的隔离级别和业务语义。

不要用错误字符串模糊匹配,也不要把所有连接错误、超时、唯一键冲突都塞进 isRetryable。特别是 Commit 阶段的网络断开,客户端可能不知道数据库究竟提交成功还是没有提交;这种“结果未知”不能按普通死锁回滚来处理。需要重试时,应先有幂等键、状态查询或业务补偿方案。

// 这是适配层的职责示意;具体类型按项目使用的驱动实现。
func retryableDatabaseError(err error) bool {
    // 例如:MySQL 只认 1213;PostgreSQL 可认 40P01,必要时再单独评估 40001。
    // 未匹配到明确错误码时返回 false,避免把业务失败变成重复写入。
    code := databaseErrorCode(err)
    return code == "1213" || code == "40P01"
}

// databaseErrorCode 应由项目的 MySQL/PostgreSQL 驱动适配器提供。
func databaseErrorCode(error) string {
    return ""
}

上面的 databaseErrorCode 是接口边界示意,不是可以直接替代驱动适配的实现。这样写的好处是:错误码清单可以独立评审,事务包装器也不会因为更换驱动而改动。

把幂等和外部副作用放在重试边界之外

数据库事务回滚只能撤销数据库里的变化,不能撤销已经发送的邮件、扣款请求、消息投递或第三方 HTTP 调用。若 work 在事务中途触发了外部副作用,随后因死锁重试,就可能重复执行外部动作。

更稳妥的做法是让事务只写入可回滚的业务状态和待发送事件,提交成功后再由可靠的 outbox 消费者执行外部动作;或者为外部操作设计幂等键,让重复请求得到同一个业务结果。重试器本身只应包围可安全重新计算的数据库工作单元。

还要区分“读到旧值后再次写入”和“重新计算业务决策”。下一次事务必须重新读取需要锁定的数据,不能把上一轮的查询结果当成新一轮事实。对于库存、余额、配额这类数值,最好让 SQL 条件和受影响行数共同参与判断,而不是在 Go 内存里拿旧值盲写。

我会用这张清单决定是否重试

检查项可以进入重试应当停止并交给上层
错误证据驱动明确报告死锁或已约定的序列化失败码字符串相似、普通超时、未知连接错误
事务状态本轮已回滚,下一轮会重新 BeginTx试图继续使用已经失败的 Tx
等待策略次数、延迟和 context 都有上限无限循环或不可取消的 Sleep
业务安全数据库工作可重新计算,外部动作有幂等保护提交结果未知却直接再次扣款或发消息
根因治理同时修正锁顺序、索引和事务长度只增加重试次数掩盖持续死锁

如果只能记住一句话,就是:死锁重试的最小单位是完整事务,判断依据是明确错误码,次数和退避必须有上限,提交结果不确定时不能装作已经回滚。把这四点固定在代码边界里,重试才是可控的容错,而不是重复写入的放大器。

为什么不能只重试失败的 SQL?

因为死锁通常意味着数据库已经回滚了当前事务,而后续 SQL 依赖的读取快照、锁和中间状态都已失效。只重试一条语句会破坏原本的业务原子性。

锁等待超时也应该按死锁重试吗?

不能直接等同。锁等待超时在不同数据库和配置下可能只回滚当前语句,也可能回滚整个事务。必须查清当前引擎的语义,确认事务状态和业务可重复性后再决定。

重试次数设置多少合适?

没有通用数字。先根据请求超时、事务平均耗时和并发冲突情况设一个很小的上限,再用指标观察;如果经常耗尽上限,应优先处理锁顺序、索引和事务范围。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Go database/sql Rows 关闭后继续 Next 会怎样Go database/sql Rows 关闭后继续 Next 会怎样
上一篇
Go database/sql Rows 关闭后继续 Next 会怎样
Go cgo 传指针到 C 怎样遵守指针规则
下一篇
Go cgo 传指针到 C 怎样遵守指针规则
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    474次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    532次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    551次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    503次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    324次使用