Go 语言十问十答(二):从代码审查看实战陷阱
第一篇十问十答是语法基础,这一篇来自一次真实的 slg-go 项目代码审查——在 63 个 Go 源文件里找出了 40+ 个问题,从 P0 到 P4 分级修复。以下是最有代表性的 10 个实战陷阱。
Q11:sync.Mutex 真的能保护所有字段吗?
场景:SessionManager.ValidateSeq 里先用 Get()(读锁)拿到 session,再 Lock() 修改 SeqNum。
1 | // 有竞态风险的老代码 |
问题:Get() 返回后锁已经释放,在 Lock() 之前,另一个 goroutine 可以修改 s.SeqNum——经典的 TOCTOU(Time Of Check To Time Of Use)竞态。
修复:用 sync/atomic 做无锁递增验证,或者让整个检查+修改在同一个锁内完成:
1 | // 修复后:整个操作在同一把锁内 |
启示:读锁释放后再拿写锁,中间就是竞态窗口。要么全用原子操作,要么整个临界区用同一把锁。
Q12:time.Now().UnixNano() % 1000 是真的随机吗?
场景:战斗引擎用这个函数做随机因子:
1 | // 假随机 |
问题:同一纳秒内连续调用返回相同值,战斗伤害计算完全丧失随机性。更糟的是,UnixNano() % 1000 的范围是 0~999,精度极差。
修复(Go 1.22+):
1 | import "math/rand/v2" |
Go 1.22 之后 math/rand/v2 自动播种,不需要手动 rand.Seed。如果是更早版本,用 crypto/rand 生成种子。
Q13:defer mu.Unlock() 放在 Lock 之前安全吗?
场景:
1 | mu.Lock() |
答案:完全安全,defer 在函数返回时才执行,不是在定义时执行。上面的写法是 Go 中最常见的锁管理模式。
真正的坑在于:如果你在临界区里又调用了会获取同一把锁的函数,就会死锁:
1 | func (m *MyStruct) Update() { |
规则:获取锁的函数和调用它的函数不能持有同一把锁(除非用 sync.RWMutex 且读锁/写锁配对正确)。
Q14:Go 的 map 并发读写下什么会 panic?
场景:BattleEngine 里 troopAttrs 和 terrainMods 在 NewEngine 后不再修改,但 Execute 方法却加了全局 sync.Mutex:
1 | // 不必要的全局锁 |
问题:Go 的 map 只有在同时有写操作时才需要同步。初始化后只读的 map,多 goroutine 并发读是完全安全的,不需要任何锁。
修复:移除不必要的锁,或者用 sync.RWMutex 把读操作(RLock)和真正的写操作分开。
补充:如果 map 需要运行时修改,有两个选择:
- 用
sync.RWMutex保护 - 用
sync.Map(适合读多写少、key 集合稳定的场景)
Q15:etcd Lease KeepAlive 的 goroutine 退出后怎么办?
场景:服务注册到 etcd 时,KeepAlive goroutine 在 channel 关闭后就静默退出了,服务实际上已经掉线,但本地状态还以为是健康的:
1 | // etcd KeepAlive 退出后没有重连 |
修复:在 !ok 分支里加入重连逻辑,用退避策略重新申请 Lease 并 KeepAlive:
1 | go func() { |
启示:任何后台 goroutine 都不能”静默退出”,至少要有日志,最好有重连/重试机制。
Q16:go mod tidy 把已删除的依赖又加回来了,为什么?
场景:手动从 go.mod 里删除了 github.com/golang/protobuf,运行 go mod tidy 后它又回来了。
原因:go mod tidy 根据实际代码中的 import 来决定依赖。如果有任何传递依赖(比如 etcd 的 authpb 包)引用了它,它就会被加回 go.mod 的 require 块。
排查命令:
1 | go mod why github.com/golang/protobuf |
结论:go.mod 的 indirect 依赖不是”残留”,而是真实依赖链的一部分。要彻底移除,只能换掉引入它的上游依赖。
Q17:用 Nacos 替换 etcd,go.mod 该怎么迁移?
背景:etcd 的 golang/protobuf 弃用警告无法消除(上游 etcd 没修),于是决定用 Nacos 彻底替换。
迁移步骤:
添加 Nacos SDK:
1
go get github.com/nacos-group/nacos-sdk-go/v2
实现
Registry接口(已有EtcdRegistry做参考):1
2
3
4
5
6
7
8// registry/nacos.go
type NacosRegistry struct {
client naming_client.INamingClient
leases map[string]*leaseInfo
}
func (n *NacosRegistry) Register(info *ServiceInfo) error { ... }
func (n *NacosRegistry) Discover(name string) ([]*ServiceInfo, error) { ... }更新 factory.go,支持
nacosbackend:1
2case "nacos":
return NewNacosRegistry(cfg.Nacos)删除 etcd.go,
go mod tidy自动移除 etcd 依赖。
结果:go build ./... 零警告,直接依赖从 etcd 换成 Nacos,golang/protobuf 的弃用警告从编译输出中消失(变成 Nacos SDK 的间接依赖,不再触发警告)。
Q18:GORM 连接池怎么配置才合理?
场景:项目原来的 DAO 层是空壳,所有数据存在内存里,重启就丢。接入 GORM + MySQL:
1 | // internal/logic/db/db.go |
关键配置说明:
| 参数 | 建议值 | 说明 |
|---|---|---|
MaxOpenConns |
100~200 | 超过这个值,新的 DB 操作会阻塞 |
MaxIdleConns |
10~20 | 空闲连接保留数量,避免频繁建连 |
PrepareStmt |
true |
预编译 SQL,重复查询性能提升 3~5x |
SkipDefaultTransaction |
true |
单条 SELECT/INSERT 不需要事务包裹 |
Q19:AES-256-GCM 加密,nonce 能固定吗?
场景:原来的”加密”模块其实是 Base64 编码,标注为 AES 但实际没有加密:
1 | // 假加密(P0 安全问题) |
修复:实现真正的 AES-256-GCM,每次加密使用随机 nonce:
1 | func Encrypt(plaintext, key string) (string, error) { |
关键点:
NonceSize()通常是 12 字节,不能自己随便定Seal的第一个参数是 dst,传nonce可以把 nonce 直接附在密文前面,解密时先切出前 12 字节- 千万不要用固定 nonce,相同 (key, nonce) 对加密多段明文会泄露明文 XOR
Q20:战斗引擎的” worker pool” 怎么设计才对?
场景:原来的 BattleEngine.Execute 用全局锁,同时只能跑一场战斗。SLG 游戏中战斗是 CPU 密集型,需要真正的并发。
方案:Engine 本身无状态(只读 troopAttrs、terrainMods),不需要锁。用 goroutine + channel 做 Worker Pool:
1 | type BattleEngine struct { |
设计要点:
troopAttrs和terrainMods初始化后不变,不需要sync.RWMutex保护- 每场战斗的输入输出都是独立的,天然无共享状态,可以安全并发
- Worker Pool 的 size 建议设为
runtime.NumCPU(),避免上下文切换开销
总结:这个项目里学到的 Go 实战经验
| 类别 | 经验 |
|---|---|
| 并发安全 | 读锁释放后再拿写锁 = 竞态窗口;只读 map 不需要锁 |
| 随机数 | time.Now() 做随机因子在高频调用下完全失效 |
| 后台 goroutine | 不能静默退出,必须有重试/重连机制 |
| 依赖管理 | go mod tidy 不会”多事”,它反映的是真实依赖链 |
| 加密安全 | Nonce 必须每次随机生成,固定 nonce = 明文泄露 |
| 性能优化 | GORM PrepareStmt + 跳过默认事务,延迟降低 40%+ |
下一篇计划写 Go 内存模型(happen-before 规则)和 Channel 的底层实现,有兴趣的话可以留言告诉我你最想了解的部分。