成为全栈·Go 后端篇·goroutine 遇上数据库:并发模型不等于业务并发安全
Go 可以同时处理很多请求,但并发执行不会自动守住唯一性、计数和状态机。真正的数据安全来自事务、约束、锁顺序和失败后的状态检查。

本文代码快照:提交 ceac4e0。并发行为以各数据库驱动测试和业务不变量为准。
前言:并发变快之前,先防止并发改错
刚换到 Go 那阵子,我一度觉得并发安全是语言附带的。直到想清楚点赞接口:两个请求同时进来,各自读到同一个旧值,各自加一写回去,两条 SQL 都没错,计数还是少了一次。
从 Node 切到 Go,一个明显变化是 Go 服务可用 goroutine 轻量并发地处理连接。于是容易产生一个错误推论:Go 有 goroutine,写业务就不需要担心并发;或者只要给共享 map 加 mutex,数据库操作自然安全。
goroutine 解决的是调度和并发执行模型,不是业务冲突定义。两个请求同时给同一篇文章点赞、两个刷新请求同时消费同一令牌、管理员同时审核一篇投稿,都可能在单个 handler 逻辑完全正确的情况下形成错误结果。需要明确哪个实体是冲突点、谁持有锁、唯一约束如何兜底、事务何时提交,以及 SQLite 单写如何限制部署。
本文把请求并发、连接池、进程锁和数据库锁拆开,使用点赞、刷新令牌和分类树作为对照,最后说明哪些结论必须在三种真实数据库上验证。
一、goroutine 让请求并发,不代表执行顺序固定
Node 的 JavaScript 主线程常以事件循环调度 I/O。它会在 await 处让出执行,但 I/O 回调仍可能交错;“单线程”并不意味着业务事务不会竞争数据库。Go net/http 在连接处理路径上可以并发执行 handler,多个 goroutine 能同时运行同一段 service 代码。
因此,两种语言都必须理解并发。区别在于 Go 允许工程师更直接地启动 goroutine,也需要更明确地管理共享内存和生命周期。HTTP handler 使用 request context 传递取消与截止时间,数据库查询通过 WithContext 接收它;请求取消后,数据库操作可以停止等待。自己启动的后台 goroutine 则不能自动获得可靠退出管理,必须有清晰的 owner、取消方式和等待机制。
在数据库层,GORM 的连接池决定有多少查询可能并发执行。并发数不等于事务安全:把 MaxOpenConns 调大只会增加同时执行的数据库操作;如果业务读取后写入的步骤没有原子化,更多连接可能让竞态更容易出现。
二、用点赞解释检查后再写的竞态
一个朴素实现可能先查关系表,没有记录就插入,再把文章点赞计数加一。两个请求可能按以下顺序交错:
请求 A:查询 user/文章关系,不存在
请求 B:查询 user/文章关系,不存在
请求 A:插入关系,计数 +1
请求 B:插入关系,计数 +1
如果没有唯一约束,会出现重复关系;若有唯一约束,第二次插入报冲突,但如果代码没有正确处理,可能返回错误或计数仍重复。应用层“先查再插”不是并发保护。
M6 将点赞关系唯一约束作为底层防线,在事务内锁定文章,再用冲突忽略方式写关系,之后按关系表真实行数更新文章 like_count。这样 MySQL 的冲突影响行数不会被误读为“新增点赞”,关系与展示计数在事务中保持一致。重复请求是幂等动作,而不是一定报错。
把这段逻辑读一遍,能看出四道防线各自的位置:
// internal/member/interactions.go(节选,省略取消点赞分支)
// Like 锁定文章后增删关系,以实际关系数维护点赞计数。
func (s *Service) Like(ctx context.Context, user, id int64, add bool) (any, error) {
var a model.Article
txErr := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
a, err = article.Published(database.Lock(tx), id)
if err != nil {
return err
}
if add {
row := model.Like{
UserID: user,
ArticleID: id,
CreatedAt: s.Now().UnixMilli(),
}
r := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
if r.Error != nil {
return r.Error
}
// MySQL 冲突更新的影响行数不代表新增点赞,计数以实际关系行为准。
var count int64
if err = tx.Model(&model.Like{}).Where("article_id = ?", id).Count(&count).Error; err != nil {
return err
}
if err = tx.Model(&model.Article{}).
Scopes(database.ActiveArticles).
Where("id = ?", id).
UpdateColumn("like_count", count).Error; err != nil {
return err
}
}
// …(取消点赞分支删除关系后,同样按当前行数回写计数)
return tx.First(&a, id).Error
})
return map[string]any{"liked": add, "likeCount": a.LikeCount}, txErr
}
database.Lock(tx) 是行锁入口;OnConflict{DoNothing: true} 让重复点赞不报错;Count 取的是关系表真实行数,而不是在旧值上加一;UpdateColumn 直接写 like_count,跳过零值判断。最后 tx.First(&a, id) 重新读回文章,返回的 likeCount 就是事务内的最新值。关系和计数要么一起生效,要么一起回滚。
该写法的语义取舍也有成本:每次互动都重新统计当前文章的点赞关系,适合教学工程中清晰验证精确计数的规模;大流量下需要测量查询成本,之后可能采用原子条件增量、异步计数或重建机制,但这些优化也要保持幂等和可恢复。

三、数据库锁与唯一约束承担不同职责
PostgreSQL/MySQL 的 database.Lock 会附加行锁,锁住事务中读出的记录,减少同一业务对象的并发修改。唯一约束则让数据库拒绝重复键。两者不等价:锁保护一次临界操作的读写顺序,唯一约束保护数据集合的结构不变量。它的实现不长,却把方言差异收在一个地方:
// internal/platform/database/transaction.go
// Lock 在支持的数据库中申请行锁,保护读后写的业务不变量。
// SQLite 依赖每实例单写连接;该机制不保证跨进程 SQLite 写入协调。
func Lock(db *gorm.DB) *gorm.DB {
if db.Dialector.Name() == "sqlite" {
return db
}
return db.Clauses(clause.Locking{Strength: "UPDATE"})
}
注意 SQLite 分支直接原样返回:它不附加 FOR UPDATE,而是依赖前面提到的单写连接。同一个函数名在两种数据库下代表两种不同的保护方式,调用方不能假设“调用了 Lock 就一定拿到行锁”。
如果每个请求都先锁用户行,再锁刷新令牌记录,多个代码路径必须遵循一致顺序。反过来先锁令牌再锁用户,另一个路径先用户后令牌,就可能形成死锁环。锁顺序是业务代码的一部分,应集中约定并在并发测试中观察数据库是否出现死锁或长时间等待。
条件更新还可以减少读后写窗口。例如只在 credentials_configured=false 时写入;检查 RowsAffected 必须恰好为 1,否则视为竞争失败。条件更新表达“前置状态仍成立才改变”,无需假设读取结果直到更新时永远有效。
锁不能替代事务。锁通常只在事务提交或回滚时释放;若外层事务不存在,锁没有预期保护周期。锁也不能保证外部对象存储与 SQL 同时提交。每项不变量都要找对工具。

四、进程 Mutex 不能跨副本
分类树操作可能需要检查父级链、阻止循环并维持最大深度。服务使用进程内 mutex 串行化该实例的树变更,数据库事务再应用实际更新。这可以减少单进程内两个拓扑修改互相覆盖的风险。
// internal/taxonomy/categories.go(节选)
// Service 负责分类树和标签目录;分类写操作在本进程互斥。
type Service struct {
DB *gorm.DB
Now func() time.Time
mu sync.Mutex
}
// Save 在事务内创建或更新分类,并同步引用文章的分类投影。
func (s *Service) Save(ctx context.Context, id int64, in values.Fields) (any, error) {
s.mu.Lock()
defer s.mu.Unlock()
var c model.Category
txErr := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.saveCategoryTx(tx, id, in, &c)
})
// …
return c, txErr
}
mu 是结构体里的私有字段,只覆盖本进程,Save 与 Delete 都先拿它再开事务。这里要看清顺序:先互斥、后事务。若反过来先开事务再抢 mutex,事务会一直占着连接等待锁,反而放大资源占用。
Mutex 有严格边界:第二个服务副本有自己的 mutex,重启也会丢失内存状态。若系统未来以多副本并发修改分类树,就要使用数据库锁、唯一约束、分布式协调或改成乐观版本控制,不能把当前 mutex 描述为“分布式安全”。
并发保护要跟随部署拓扑。本文快照是单机教学工程的进程内保护,不是任意水平扩容方案。拓扑改变时,需要重新审视锁粒度和冲突协议。
五、SQLite 的单写限制必须写进运行模型
SQLite 使用文件数据库。M6 的 database.Open 为 SQLite 设置 WAL、外键和 busy timeout,并把连接池最大打开数限制为 1。此策略在一个应用实例内串行写访问,减少连接竞争,但不消除另一个进程同时写同一文件的情况,也不支持多个应用实例共享文件进行高并发写。
SQLite 下 database.Lock 不附加 FOR UPDATE 行锁;业务正确性仍依赖事务、条件更新和唯一约束,写竞争则可能等待或得到 busy 错误。测试需要用临时文件库观察真实行为,不能只用 in-memory DB 测试单个连接上的顺序路径。
这说明“兼容 SQLite”并不意味着“可按 PostgreSQL 的多副本拓扑部署”。数据库选择与运行架构相互约束,连接池数字本身并不是容量承诺。
六、昂贵计算和锁应分开放置
密码 bcrypt 计算可能较慢,刷新令牌操作则需要串行化账号状态。若先锁用户行,再执行 bcrypt,其他针对同一账号的请求会长时间等待。M6 在获取数据库锁之前计算新密码哈希,再进入事务锁用户、复核旧哈希、更新密码并撤销刷新会话。
这种做法有一个复核环节:哈希计算期间账号可能已经被另一请求改密,所以拿锁后必须确认当前密码仍与此前读取的版本相同,否则拒绝覆盖。把计算移到锁外减少锁占用,但让前态复核成为正确性必需步骤。
同样,外部 HTTP 调用、文件读取、长 CPU 任务也不应无脑放在事务锁持有期间。事务越长,锁等待与连接占用越大。若业务要求外部效果与数据库提交一致,应设计 outbox 或补偿机制,而不是期望 SQL transaction 包住网络请求。
七、并发测试要看最终不变量
只让 100 个 goroutine 同时请求并断言全部返回 200,不足以证明正确。要针对业务定义后置状态:
| 点赞 | 同一用户重复添加 | 关系一条,计数只反映一条 |
| 点赞/取消 | 交错添加与取消 | 关系和文章计数精确一致 |
| refresh | 同一旧令牌并发旋转 | 只有有效消费路径签发新会话,重放按策略撤销 |
| 首次设密 | 同账号同时提交不同用户名 | 仅一个写入成功,其余冲突,旧刷新会话按规则失效 |
| 分类移动 | 并发改父分类 | 不产生环或超过深度上限 |
| SQLite 写入 | 多请求触及同一文件 | 不丢数据,busy/取消能被明确处理 |
测试前固定时钟、创建独立 fixture,并使用 race detector 检查 Go 内存数据竞争。Race 通过只证明共享内存访问满足 Go race detector 规则,不证明数据库不变量。反过来,数据库事务测试也不能检查进程 map 的数据竞争,两者需要不同工具。
小结:并发安全来自不变量的组合
goroutine 提供并发执行,连接池提供数据库连接复用,Mutex 保护本进程共享内存,行锁和唯一约束保护数据库状态,事务定义原子边界,条件更新确认前态仍成立。每种机制只负责一部分。
在改并发代码之前,先写下允许出现的最终状态,再决定用约束、事务、行锁、互斥或补偿。最后在真实目标数据库上制造竞争,检查数据状态与错误结果。并发功能可用和并发不变量成立,是两种不同的验收。
延伸阅读
- 跨语言认证兼容:Node 与 Go 如何互认 JWT 和密码
- 点赞系统:幂等点赞与计数原子增减
- 数据库选型:关系型还是文档型
- 通知系统:事件消费与已读态管理
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer






