当前位置: 首页 > news >正文

Go 事务里的 defer:你以为它在提交后跑,其实跑在提交前

前言:一行看似无害的 defer

最近在维护一个 Go + GORM 写的服务,用户注册成功后要往日志表里写几条记录。

代码大概长这样:

return DB.Transaction(func(tx *gorm.DB) error {if err := tx.Create(user).Error; err != nil {return err}if common.QuotaForNewUser > 0 {defer RecordLog(user.Id, LogTypeSystem, "新用户注册")}// ... 其他事务内的写操作return nil
})

RecordLog 内部会根据 userId 反查用户名,把它一起写进日志表,方便后台运营看:

func RecordLog(userId int, logType int, content string) {username, _ := GetUsernameById(userId, false)  // ← 反查用户名log := &Log{ UserId: userId, Username: username, ... }LOG_DB.Create(log)
}

代码上线后,功能"看着"是好的:日志确实写进去了。但有一天有人翻日志,发现一件怪事:

所有新用户注册的那条日志,username 字段全是空的。

而且只有"注册时"那几条空,正常使用过程中产生的日志(消费、充值)都正常带着用户名。

第一反应:是不是 defer 没生效?

看到 defer,第一反应都是「defer 是不是漏了」。但翻 LOG 表能看到日志确实进来了,只是 username 字段为空。所以 defer 跑了,只是跑出来的结果不对。

那就是 GetUsernameById 查不到用户呗?可用户明明已经 tx.Create 了啊,主键 user.Id 都拿到了。

真相:你以为的 defer,其实跑早了

这里有两个常被混淆的点,叠在一起就成了 bug。

点 1:defer 绑定的是它"所在的那个函数",不是外层逻辑

很多人写 defer 时心里默念的是「等整块逻辑跑完再执行」。这是错的defer 的语义非常死板:

defer 注册的调用,会在当前函数返回时执行 —— 仅此一个函数。

放到我们这段代码里,defer 注册在传给 Transaction匿名回调函数里。所以执行顺序是:

1. tx.Create(user)           ← 用户写入事务,但还没 commit
2. defer 注册一个延迟调用
3. 匿名函数 return nil
4. 此时 defer 触发!RecordLog 跑了 ← 注意:事务还没提交
5. defer 执行完,匿名函数才真正返回到 Transaction
6. Transaction 看到 nil,决定 commit 事务
7. 这里事务才真正落库

第 4 步发生在第 7 步之前。这就是问题的源头。

点 2:事务还没提交时,外面是看不到的

RecordLog 内部反查用户名走的是哪个连接?

func GetUsernameById(id int, fromDB bool) (username string, err error) {err = DB.Model(&User{}).Where("id = ?", id).Select("username").Find(&username).Error//    ↑ 注意这里是全局 DB,不是事务里的 tx
}

走的是全局 DB

数据库的事务隔离规则告诉我们:在事务 A 没 commit 之前,事务 B 是读不到 A 写入的数据的(不管隔离级别是 READ COMMITTED 还是 REPEATABLE READ,都拦得住)。

所以这一步发生了什么:

全局 DB(连接 B):SELECT username FROM users WHERE id = 123
事务 tx(连接 A):刚 INSERT 了 id=123 的用户,但还没 commit↓
连接 B 看不到这条记录 → 返回空字符串 → err 被 _ 忽略↓
log.Username = ""  ← bug 现身

把两点叠起来

        ┌─────────────────────────────────────────────┐│ DB.Transaction(回调)                         ││  ┌────────────────────────────────────────┐ ││  │ tx.Create(user)        // INSERT       │ ││  │ defer RecordLog(...)   // 延迟         │ ││  │ return nil                             │ ││  │   └─ defer 触发:                      │ ││  │       RecordLog                        │ ││  │         └─ GetUsernameById             │ ││  │             └─ 走全局 DB ❌ 查不到     │ ││  └────────────────────────────────────────┘ ││  GORM 看到 nil → COMMIT                      │└─────────────────────────────────────────────┘

defer 没坏,RecordLog 没坏,GetUsernameById 也没坏。它们只是被以错误的顺序串起来了

顺手揪出来的隐性 bug

定位到原因后又仔细看了一眼这段代码,发现还藏了两个雷:

雷 1:事务回滚时,日志反而会留下来

defer 是只要注册过就一定会跑。如果后面某个 tx.Save 失败,事务回滚,用户其实没建成功 —— 但 defer RecordLog 还是会执行,给一个不存在的用户写了一条日志。后台运营看到只会一脸懵。

雷 2:日志写入拖长了事务

RecordLog 里有一次 LOG_DB.Create,这是一次跨表的写。哪怕它"看起来"在 return 后才跑,但根据上面分析,它实际是在事务 commit 之前跑的。这意味着主事务持锁的时间被无端拉长了,对 MySQL / PostgreSQL 的并发吞吐很不友好。

修复思路:副作用一律放事务外

修复思路一句话:事务内只做"数据一致性"相关的写操作,副作用统统放到事务提交之后

// 1. 先把事务跑完
if err := DB.Transaction(func(tx *gorm.DB) error {if err := tx.Create(user).Error; err != nil {return err}// ... 其他纯 DB 写操作return nil
}); err != nil {return err  // 事务失败,直接返回,连日志都不写
}// 2. 事务提交成功后,再做副作用
if common.QuotaForNewUser > 0 {RecordLog(user.Id, LogTypeSystem, "新用户注册")
}

这样改完:

  • RecordLog 跑的时候事务已经 commit,全局 DB 能读到新用户,username 不再是空。
  • 事务失败就早 return,不会留脏日志。
  • 主事务持锁时间缩短到最短。

真正的经验:什么该进事务,什么不该

很多教程讲事务时只讲「怎么开、怎么提交、怎么回滚」,但最关键的问题其实是:哪些操作应该放进事务里?

我自己的判断标准很简单 —— 问一句:这个操作回滚的时候能跟着回滚吗?

操作类型 能跟着回滚? 该放进事务?
INSERT / UPDATE / DELETE 同一个 DB ✅ 能 ✅ 进
写另一个独立的 DB / 日志表(跨连接) ❌ 不能 ❌ 出
发邮件、发短信、推送 webhook ❌ 不能 ❌ 出
调外部 HTTP API ❌ 不能 ❌ 出
清 Redis 缓存、发 MQ 消息 ❌ 不能 ❌ 出
写日志(依赖刚提交的数据) ❌ 读不到 ❌ 出

凡是「事务一回滚就该跟着没事」的操作 —— 进事务。
凡是「就算事务回滚也会留下痕迹」的操作 —— 出事务,而且要放在事务提交后做。

这背后是一个很基本但容易忽视的原则:事务保证的是数据库内的一致性,不是世界的一致性。邮件发出去就收不回来,webhook 推出去就到对面了,事务回滚救不回来。所以这种东西必须等数据库这边"板上钉钉"了再做。

关于 defer 的小补充

误区 1:以为 defer 等到外层逻辑结束才跑

func outer() {inner(func() {defer cleanup()  // ← 在 inner 的回调内})// cleanup 在这里之前就跑完了,不是 outer 返回时
}

误区 2:以为事务回调里的 defer 跟事务提交是一前一后

跟上面一样的错。事务回调里的 defer 跑在「回调返回后、GORM 提交前」这个夹缝里,先于事务提交

误区 3:用 defer 处理失败时不该执行的副作用

defer 一旦注册就会跑,不挑成功失败。要做"只在成功时执行"的事,要么放函数末尾正常调用,要么用具名返回值在 defer 里检查 err。

写在最后

这个 bug 现象很小(日志缺个用户名),但暴露出来的问题是基础知识层面的:事务边界、defer 时机、连接隔离。这三件事单独拎出来都是面试常考题,但叠在一起、藏在 ORM 后面,就很容易蒙混过关,直到某天日志统计出问题才被翻出来。

结论一句话:事务里只放数据库的事,副作用都赶出去。defer 不是事务的好朋友,它跑得比你想的早。

http://www.jsqmd.com/news/902420/

相关文章:

  • 2026年质量管理指南:泡泡图(Bubble Drawing)与自动化检验计划实战
  • Multilingual-E5-small实战教程:构建跨语言搜索引擎的10个步骤
  • 从Twonky Server漏洞看企业老旧DLNA服务的安全风险与排查清单
  • 2026年5月西安代办公司注册机构TOP5权威排行 - 奔跑123
  • ShinyHunters 勒索团伙入侵 7-Eleven,超 18 万人个人信息泄露!
  • 5分钟掌握WeChatMsg:永久保存微信聊天记录的终极解决方案
  • 2026年钢制隔音门价格行情:隆电昌盛性价比高吗? - myqiye
  • 丽水高复学校哪家靠谱?2026丽水高考复读优选东阳高复中心 - 玖叁鹿
  • Kubernetes网络管理:深入理解Ingress配置
  • 5分钟完全指南:免费开源自动化神器KeymouseGo彻底告别重复劳动
  • 别再只读角度了!用AS5600+STM32实现步进电机速度环的保姆级教程
  • 3分钟解锁音乐自由:ncmdump终极NCM格式转换指南
  • 如何解锁NVIDIA显卡隐藏设置:NVIDIA Profile Inspector完全配置指南
  • 番茄小说下载器完整指南:如何打造个人离线数字图书馆
  • 深入Tesla Model 3安全通信:拆解Hermes代理与证书轮换机制
  • Bonsai-8B-mlx-1bit优化技巧:提升推理速度的5个关键配置
  • QMCDecode:3分钟解锁QQ音乐加密音频,让音乐不再受格式束缚
  • 海口欧米茄浪琴回收价格 五大平台 PK - 合扬奢侈品交易中心
  • 抖音无水印下载终极指南:5步掌握高效批量下载技巧
  • Harness Engineering到底是什么?概念、实战与争议,一次全部讲清楚
  • LinkSwift网盘直链下载助手:免费解锁九大网盘下载限制的终极指南
  • DLSS Swapper完全指南:3步轻松管理游戏超采样文件,免费提升显卡性能
  • 微信聊天记录永久保存指南:如何用WeChatMsg守护你的数字记忆
  • 新手村第一关:POJ 1000题A+B Problem保姆级通关攻略(从注册到AC)
  • AMD处理器性能优化终极指南:3步掌握硬件调优完整解决方案
  • 如何用WeChatMsg永久保存你的微信聊天记忆:免费工具完全指南
  • 工业视觉新手的福音:用Halcon DLT V22.06搞定你的第一份深度学习标注数据集
  • 呼伦贝尔黄金上门回收怎么选?福运来口碑领跑 - 上门黄金回收
  • 实战避坑:在FPGA/SoC中实现PCIe数据链路层时,Ack/Nak机制的那些设计陷阱与优化技巧
  • 3步搞定跨平台字体统一:PingFangSC免费字体解决方案