随机数这东西,平时看起来很简单:抽一个奖、打散一个列表、给测试数据造点噪声、做个灰度分流。可我见过不少线上问题,最后都绕回一句话:你到底想要“随机”,还是想要“可复现”,还是想要“安全”?这三个目标混在一起,代码迟早会出怪事。
Go 1.22 加了 math/rand/v2,名字变了一些,生成器也换了思路。但今天这篇不写发布说明,我按工程场景讲:什么时候用顶层函数,什么时候自己 new 一个 Rand,测试里怎么固定 seed,抽奖和 token 为什么不能用同一套随机数。
先把三种随机需求分开
第一种是业务随机,比如抽奖、随机推荐、打散列表。你关心的是分布别太离谱、代码简单、结果不需要保密。第二种是测试随机,比如 fuzz 旁边的辅助数据、压测样本、模拟订单流。你关心的是失败后能复现,今天跑出来的坏样本明天还能重放。
第三种是安全随机,比如 token、验证码、密钥、nonce。这个场景最容易被写错:它看起来也是随机数,但绝对不能靠 math/rand 或 math/rand/v2 糊弄。安全敏感的随机,直接去 crypto/rand。
rand/v2 适合什么
math/rand/v2 实现的是伪随机数生成器,适合模拟、测试、抽样、洗牌、灰度分桶这类非安全场景。官方文档也明确提醒:安全敏感工作不要用它,要用 crypto/rand。
所以我在 review 里会先问用途,而不是先问 API。你要的是“每次启动都不一样”,还是“给我一个固定 seed,失败能复现”?你要的是“给用户抽一个活动奖品”,还是“给登录态发一个 bearer token”?问题问清楚,API 基本就选对一半了。
命名变化:Intn 到 IntN,不只是大小写
老包里大家习惯写 rand.Intn(100),到了 v2 里变成 rand.IntN(100)。还有 Int32N、Int64N、UintN 这类更直观的名字。迁移时别靠手感改,最好用一次全量扫描,把旧调用列出来逐个判断用途。
这里有个容易忽略的点:如果旧代码里同时有业务随机、测试随机、安全随机,不能一股脑替换成 v2。迁移不是替换字符串,而是顺手把用途分层。
import rand "math/rand/v2"
func pickBucket(n int) int {
if n
可复现测试:别再靠全局随机碰运气
我最推荐新手先掌握的,不是顶层 rand.IntN,而是自己创建一个带固定 seed 的随机源。这样测试失败时,你能把 seed 打进日志,下次用同一个 seed 重放,而不是对着“偶发失败”四个字发呆。
比如用 rand.New(rand.NewPCG(seed1, seed2)) 创建一个独立的生成器。这个生成器只服务一个测试用例或一个压测任务,生命周期清楚,结果也更容易复现。
import rand "math/rand/v2"
func makeCases(seed1, seed2 uint64, n int) []int {
r := rand.New(rand.NewPCG(seed1, seed2))
out := make([]int, n)
for i := range out {
out[i] = r.IntN(10_000)
}
return out
}
PCG 和 ChaCha8 怎么选
写业务代码时你不用把随机生成器论文背下来。我的简单建议是:需要明确 seed、追求可复现、做模拟测试,优先看 PCG;想用 v2 顶层函数,Go 会帮你使用默认生成器。ChaCha8 的存在,让误用 math/rand/v2 做一些随机字节时不至于像过去那么脆,但这不等于它可以替代 crypto/rand。
这句话很重要:安全边界不是“看起来更随机”,而是用对包。生成 token、密钥、一次性验证码,直接用 crypto/rand。不要拿 math/rand/v2 里的任何东西给自己找理由。
Shuffle:洗牌也要问用途
Shuffle 很常用,比如推荐列表打散、AB 样本顺序随机、测试数据排列。业务上用顶层 rand.Shuffle 很方便;测试里如果你希望失败可复现,就用自己创建的 r.Shuffle。
我会把“是否需要重放”当成分界线。线上随机展示,顶层函数通常够用;测试或压测生成样本,最好传入固定 seed。否则今天 CI 挂了,明天同一段测试又过了,你连现场都找不回来。
func shuffleForTest(seed1, seed2 uint64, xs []string) {
r := rand.New(rand.NewPCG(seed1, seed2))
r.Shuffle(len(xs), func(i, j int) {
xs[i], xs[j] = xs[j], xs[i]
})
}
灰度分流别只靠随机数
很多人做灰度时会写 rand.IntN(100) < 10,看起来是 10% 流量,但用户今天进 A、明天进 B,很可能体验不稳定。真正的灰度分流通常更适合用用户 ID、租户 ID、设备 ID 做 hash,再映射到桶。
rand/v2 可以用于一次性抽样,但如果你需要“同一个用户稳定落在同一个桶”,随机数不是最好的工具。这里不是 API 问题,是业务语义问题。
并发里别随便共享可复现 Rand
顶层函数用起来很省心,也适合大多数简单场景。但当你自己创建 Rand,尤其是为了固定 seed 做可复现结果时,就别随手把同一个实例丢给一堆 goroutine 抢着用。即使代码能跑,输出顺序也会被调度影响,复现价值大打折扣。
我的做法通常是:每个测试用例一个 Rand,每个 worker 用自己的 seed 派生一个 Rand,或者明确加锁并接受顺序依赖。别让“可复现测试”最后变成“看 goroutine 调度心情”。
安全随机:单独拎出来写
如果你要生成 token,就别在 rand/v2 和 crypto/rand 之间犹豫了。代码可以稍微长一点,但边界一定要清楚。
import crand "crypto/rand"
func tokenBytes(n int) ([]byte, error) {
b := make([]byte, n)
if _, err := crand.Read(b); err != nil {
return nil, err
}
return b, nil
}
我不喜欢看到业务代码里用 math/rand 或 math/rand/v2 拼 token。它可能短期没有事故,但一旦出事,通常不是“改一行代码”能解释过去的。
我的迁移清单
先扫描旧的
math/rand调用,按业务随机、测试随机、安全随机分组。安全敏感路径直接迁到
crypto/rand,不要进 rand/v2 讨论。需要可复现的测试,使用固定 seed 创建独立
Rand。普通非安全随机,可以考虑 v2 顶层函数,例如
IntN和Shuffle。灰度分流优先确认是否需要用户稳定分桶,需要稳定就考虑 hash。
压测或 CI 失败时,把 seed 记进日志,方便重放。
不要为了“新包”而迁移,先挑最容易踩坑的随机路径改。
最后聊两句
math/rand/v2 是个好工具,但它真正帮你的地方,不是把 Intn 改成 IntN,而是逼你重新想清楚随机数的用途。业务抽样、测试复现、安全令牌,本来就不该混在一个脑子里。
我自己的经验是:随机数代码要写得比看起来更啰嗦一点。把 seed、用途、边界写清楚,未来排查 CI 偶发失败、活动抽奖异常、灰度分流不稳定时,你会感谢今天多写的这些注释和封装。
参考资料:Go 官方博客:Evolving the Go Standard Library with math/rand/v2、math/rand/v2 官方文档。

Go unique 实战:别再用全局 map 硬做字符串去重
