第一篇十问十答是语法基础,这一篇来自一次真实的 slg-go 项目代码审查——在 63 个 Go 源文件里找出了 40+ 个问题,从 P0 到 P4 分级修复。以下是最有代表性的 10 个实战陷阱。

Q11:sync.Mutex 真的能保护所有字段吗?

场景:SessionManager.ValidateSeq 里先用 Get()(读锁)拿到 session,再 Lock() 修改 SeqNum。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 有竞态风险的老代码
func (m *SessionManager) Get(playerID int64) (*PlayerSession, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[playerID]
return s, ok
}

func (m *SessionManager) ValidateSeq(playerID int64, seq uint32) bool {
s, ok := m.Get(playerID) // RLock → RUnlock,锁已释放!
if !ok { return false }
m.mu.Lock()
defer m.mu.Unlock()
if seq <= s.SeqNum { return false }
s.SeqNum = seq // 此时 s 可能已被其他 goroutine 修改
return true
}

问题:Get() 返回后锁已经释放,在 Lock() 之前,另一个 goroutine 可以修改 s.SeqNum——经典的 TOCTOU(Time Of Check To Time Of Use)竞态。

修复:用 sync/atomic 做无锁递增验证,或者让整个检查+修改在同一个锁内完成:

1
2
3
4
5
6
7
8
9
10
// 修复后:整个操作在同一把锁内
func (m *SessionManager) ValidateSeq(playerID int64, seq uint32) bool {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[playerID]
if !ok { return false }
if seq <= s.SeqNum { return false }
s.SeqNum = seq
return true
}

启示:读锁释放后再拿写锁,中间就是竞态窗口。要么全用原子操作,要么整个临界区用同一把锁。


Q12:time.Now().UnixNano() % 1000 是真的随机吗?

场景:战斗引擎用这个函数做随机因子:

1
2
3
4
// 假随机
func mathRand() float64 {
return float64(time.Now().UnixNano() % 1000)
}

问题:同一纳秒内连续调用返回相同值,战斗伤害计算完全丧失随机性。更糟的是,UnixNano() % 1000 的范围是 0~999,精度极差。

修复(Go 1.22+):

1
2
3
4
5
import "math/rand/v2"

func mathRand() float64 {
return rand.Float64() // 真随机 [0,1)
}

Go 1.22 之后 math/rand/v2 自动播种,不需要手动 rand.Seed。如果是更早版本,用 crypto/rand 生成种子。


Q13:defer mu.Unlock() 放在 Lock 之前安全吗?

场景:

1
2
3
mu.Lock()
defer mu.Unlock()
// ... 临界区 ...

答案:完全安全,defer 在函数返回时才执行,不是在定义时执行。上面的写法是 Go 中最常见的锁管理模式。

真正的坑在于:如果你在临界区里又调用了会获取同一把锁的函数,就会死锁:

1
2
3
4
5
func (m *MyStruct) Update() {
m.mu.Lock()
defer m.mu.Unlock()
m.internalUpdate() // 如果这个函数也要 Lock(),死锁!
}

规则:获取锁的函数和调用它的函数不能持有同一把锁(除非用 sync.RWMutex 且读锁/写锁配对正确)。


Q14:Go 的 map 并发读写下什么会 panic?

场景:BattleEngine 里 troopAttrs 和 terrainMods 在 NewEngine 后不再修改,但 Execute 方法却加了全局 sync.Mutex:

1
2
3
4
5
6
// 不必要的全局锁
func (e *BattleEngine) Execute(order *BattleOrder) (*BattleResult, error) {
e.mu.Lock()
defer e.mu.Unlock()
// ... 只读 troopAttrs / terrainMods ...
}

问题:Go 的 map 只有在同时有写操作时才需要同步。初始化后只读的 map,多 goroutine 并发读是完全安全的,不需要任何锁。

修复:移除不必要的锁,或者用 sync.RWMutex 把读操作(RLock)和真正的写操作分开。

补充:如果 map 需要运行时修改,有两个选择:

  • 用 sync.RWMutex 保护
  • 用 sync.Map(适合读多写少、key 集合稳定的场景)

Q15:etcd Lease KeepAlive 的 goroutine 退出后怎么办?

场景:服务注册到 etcd 时,KeepAlive goroutine 在 channel 关闭后就静默退出了,服务实际上已经掉线,但本地状态还以为是健康的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// etcd KeepAlive 退出后没有重连
go func() {
for {
select {
case _, ok := <-ch:
if !ok {
// channel closed,goroutine 直接退出!
return
}
case <-ctx.Done():
return
}
}
}()

修复:在 !ok 分支里加入重连逻辑,用退避策略重新申请 Lease 并 KeepAlive:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
go func() {
for {
select {
case _, ok := <-ch:
if !ok {
// 尝试重连,退出前最多重试 N 次
if !r.tryRekeepAlive(ctx) {
return
}
continue
}
case <-ctx.Done():
return
}
}
}()

启示:任何后台 goroutine 都不能”静默退出”,至少要有日志,最好有重连/重试机制。


Q16:go mod tidy 把已删除的依赖又加回来了,为什么?

场景:手动从 go.mod 里删除了 github.com/golang/protobuf,运行 go mod tidy 后它又回来了。

原因:go mod tidy 根据实际代码中的 import 来决定依赖。如果有任何传递依赖(比如 etcd 的 authpb 包)引用了它,它就会被加回 go.mod 的 require 块。

排查命令:

1
2
3
4
5
6
go mod why github.com/golang/protobuf
# 输出:
# (root)
# → github.com/etcd-io/etcd/client/v3
# → github.com/etcd-io/etcd/api/v3/authpb
# → github.com/golang/protobuf/proto

结论:go.mod 的 indirect 依赖不是”残留”,而是真实依赖链的一部分。要彻底移除,只能换掉引入它的上游依赖。


Q17:用 Nacos 替换 etcd,go.mod 该怎么迁移?

背景:etcd 的 golang/protobuf 弃用警告无法消除(上游 etcd 没修),于是决定用 Nacos 彻底替换。

迁移步骤:

  1. 添加 Nacos SDK:

    1
    go get github.com/nacos-group/nacos-sdk-go/v2
  2. 实现 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) { ... }
  3. 更新 factory.go,支持 nacos backend:

    1
    2
    case "nacos":
    return NewNacosRegistry(cfg.Nacos)
  4. 删除 etcd.go,go mod tidy 自动移除 etcd 依赖。

结果:go build ./... 零警告,直接依赖从 etcd 换成 Nacos,golang/protobuf 的弃用警告从编译输出中消失(变成 Nacos SDK 的间接依赖,不再触发警告)。


Q18:GORM 连接池怎么配置才合理?

场景:项目原来的 DAO 层是空壳,所有数据存在内存里,重启就丢。接入 GORM + MySQL:

1
2
3
4
5
6
7
8
9
10
11
12
// internal/logic/db/db.go
func InitMySQL(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
PrepareStmt: true, // 预编译,提升重复查询性能
SkipDefaultTransaction: true, // 非事务操作跳过 BEGIN/COMMIT
})
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(100) // 根据 Logic 节点数调整
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(time.Hour)
return db, nil
}

关键配置说明:

参数 建议值 说明
MaxOpenConns 100~200 超过这个值,新的 DB 操作会阻塞
MaxIdleConns 10~20 空闲连接保留数量,避免频繁建连
PrepareStmt true 预编译 SQL,重复查询性能提升 3~5x
SkipDefaultTransaction true 单条 SELECT/INSERT 不需要事务包裹

Q19:AES-256-GCM 加密,nonce 能固定吗?

场景:原来的”加密”模块其实是 Base64 编码,标注为 AES 但实际没有加密:

1
2
3
4
// 假加密(P0 安全问题)
func Encrypt(plaintext string) string {
return base64.StdEncoding.EncodeToString([]byte(plaintext))
}

修复:实现真正的 AES-256-GCM,每次加密使用随机 nonce:

1
2
3
4
5
6
7
8
9
10
func Encrypt(plaintext, key string) (string, error) {
block, _ := aes.NewCipher([]byte(key)[:32])
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}

关键点:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type BattleEngine struct {
troopAttrs map[uint32]*TroopAttr // 初始化后只读,无锁安全
terrainMods map[uint32]map[string]float64
taskQueue chan *BattleTask
wg sync.WaitGroup
}

func (e *BattleEngine) Start(workers int) {
e.taskQueue = make(chan *BattleTask, workers*2)
for i := 0; i < workers; i++ {
go e.worker()
}
}

func (e *BattleEngine) ExecuteAsync(order *BattleOrder) <-chan *BattleResult {
resultCh := make(chan *BattleResult, 1)
e.taskQueue <- &BattleTask{Order: order, Result: resultCh}
return resultCh
}

设计要点:

  • 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 的底层实现,有兴趣的话可以留言告诉我你最想了解的部分。