Go 随机数入门:math/rand、crypto/rand 和安全 Token

本文详解 Go 中 math/rand 和 crypto/rand 的区别与用法,覆盖测试随机数生成、业务抽样、安全 Token、验证码等场景,附带常见错误与最佳实践。

随机数场景总览与核心区别

很多程序都需要随机数来完成各种任务:生成验证码、抽样、打乱列表顺序、生产测试数据、模拟游戏掉落概率、创建会话 Token、生成密码重置链接。这些场景虽然都叫"随机",但安全要求完全不同。在 Go 语言中,最常见的是两个包:math/rand 和 crypto/rand。前者适合模拟、测试、非安全业务随机;后者适合安全 Token、密钥、密码重置码这类绝对不能被预测的场景。

混用这两个包是生产环境中常见的初学者错误。math/rand 生成的是伪随机数(Deterministic),如果攻击者知道了种子和算法,就能够精确预测后续生成的所有数字序列。它完全不适合生成会话 Token 或 API 密钥。crypto/rand 使用操作系统提供的加密安全随机源(在 Linux 上通常是 /dev/urandom),速度较慢,但具有密码学级别的不可预测性,是安全敏感场景的 mandatory choice。

理解这两个包的本质差异,是编写安全 Go 代码的第一步。

math/rand 详解与实战演练

基本用法与种子选择

math/rand 默认使用一个全局的随机源。在并发场景下,使用共享的全局源会产生竞争条件。最佳实践是为包含随机数逻辑的每个模块或每个 goroutine 创建独立的 *rand.Rand 实例:

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	// 创建独立随机源,避免全局竞争
	src := rand.NewSource(time.Now().UnixNano())
	r := rand.New(src)

	// 生成 [0, 100) 区间内的整数
	n := r.Intn(100)
	fmt.Printf("随机整数: %d\n", n)

	// 生成 [100, 199] 区间内的整数
	m := r.Intn(100) + 100
	fmt.Printf("偏移随机整数: %d\n", m)

	// 生成 0.0 ~ 1.0 之间的浮点数
	f := r.Float64()
	fmt.Printf("随机浮点数: %.6f\n", f)

	// 生成正态分布的随机数(均值 0,标准差 1)
	normal := r.NormFloat64()
	fmt.Printf("正态分布: %.4f\n", normal)
}

独立随机源传入函数参数

把 *rand.Rand 作为参数传入函数中,可以让测试代码复用这个函数,并使用固定种子来保证结果的可复现性:

package main

import (
	"errors"
	"fmt"
	"math/rand"
)

func PickName(names []string, r *rand.Rand) (string, error) {
	if len(names) == 0 {
		return "", errors.New("names is empty")
	}
	return names[r.Intn(len(names))], nil
}

func main() {
	// 生产环境:使用当前时间作为种子
	prodR := rand.New(rand.NewSource(time.Now().UnixNano()))
	name, _ := PickName([]string{"Go", "Rust", "Python"}, prodR)
	fmt.Println(name)

	// 测试环境:使用固定种子
	testR := rand.New(rand.NewSource(42))
	name2, _ := PickName([]string{"Go", "Rust", "Python"}, testR)
	fmt.Println(name2) // 永远返回相同结果
}

打乱切片顺序

func ShuffleStrings(items []string, r *rand.Rand) []string {
	shuffled := make([]string, len(items))
	copy(shuffled, items)
	r.Shuffle(len(shuffled), func(i, j int) {
		shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
	})
	return shuffled
}

常见错误:忘记指定种子

如果在 math/rand 中没有显式指定种子,rand.NewSource(1) 会被默认使用。这意味着每次程序运行时都会得到完全相同的随机数序列:

// 错误示例:没有种子
fmt.Println(rand.Intn(100)) // 每次运行结果完全一样

// 正确方式:明确指定种子
rand.Seed(time.Now().UnixNano())
fmt.Println(rand.Intn(100))

math/rand 性能对比

math/rand 的性能非常高。在典型的基准测试中,单次 Int63() 调用只需要约 1.5 纳秒,足以满足绝大多数业务随机需求。

crypto/rand 详解与安全 Token 生成

生成安全的随机字节

package main

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"fmt"
)

// RandomBytes 生成 n 字节的安全随机数据
func RandomBytes(n int) ([]byte, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return nil, fmt.Errorf("read random bytes: %w", err)
	}
	return b, nil
}

// NewToken 生成 URL 安全的 Base64 Token(32 字节 ≈ 256 位熵)
func NewToken() (string, error) {
	b, err := RandomBytes(32)
	if err != nil {
		return "", err
	}
	// RawURLEncoding 不带 = 填充,适合放进 URL query 或 header
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// SecureHex 生成十六进制字符串(适合数据库主键、API Key)
func SecureHex(n int) (string, error) {
	b, err := RandomBytes(n)
	if err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

func main() {
	token, err := NewToken()
	if err != nil {
		panic(err)
	}
	fmt.Printf("Token: %s (长度 %d)\n", token, len(token))

	hexToken, _ := SecureHex(16)
	fmt.Printf("Hex Token: %s (长度 %d)\n", hexToken, len(hexToken))
}

绝对不要用 math/rand 做安全 Token

以下代码是生产环境中的严重安全漏洞:

// 危险!不要这样做!
unsafeToken := fmt.Sprintf("%d", rand.Int())

攻击者可以通过暴力枚举预测这个序列。永远使用 crypto/rand 生成任何涉及认证、授权、会话的安全 Token。

两种随机源的性能 Benchmark 对比

package main

import (
	"crypto/rand"
	"math/rand"
	"testing"
	"time"
)

func BenchmarkMathRand(b *testing.B) {
	r := rand.New(rand.NewSource(time.Now().UnixNano()))
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = r.Intn(100)
	}
}

func BenchmarkCryptoRand(b *testing.B) {
	buf := make([]byte, 8)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_, _ = rand.Read(buf)
	}
}

// 典型结果(受环境差异影响):
// BenchmarkMathRand-10        ~  1.5 ns/op
// BenchmarkCryptoRand-10      ~ 50.0 ns/op

结果显示 math/rand 比 crypto/rand 快约 30 倍。如果你的场景不需要安全性,math/rand 是更明智的选择;但如果涉及安全,crypto/rand 额外的开销是必须接受的。

随机字符串生成实战案例

测试数据生成器

package main

import (
	"math/rand"
	"strings"
)

const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"

// RandomString 生成指定长度的伪随机字符串,适合测试数据
func RandomString(r *rand.Rand, n int) string {
	var sb strings.Builder
	for i := 0; i < n; i++ {
		sb.WriteByte(alphabet[r.Intn(len(alphabet))])
	}
	return sb.String()
}

安全密码生成器

package main

import (
	"crypto/rand"
	"fmt"
)

// GeneratePassword 生成高强度随机密码
func GeneratePassword(length int) (string, error) {
	const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"
	b := make([]byte, length)
	for i := range b {
		rbyte := make([]byte, 1)
		_, err := rand.Read(rbyte)
		if err != nil {
			return "", err
		}
		b[i] = chars[int(rbyte[0])%len(chars)]
	}
	return string(b), nil
}

注意:上述取模方式在字符集长度不是 2 的幂时,存在轻微的分布偏差。对密码生成器来说,这种偏差通常可以接受。如果要求完美的均匀分布,可以使用拒绝采样法(rejection sampling)。

验证码设计最佳实践与常见错误

纯数字验证码

package main

import (
	"crypto/rand"
	"fmt"
	"math/big"
)

// SecureCode 生成 6 位数字验证码,使用 crypto/rand
func SecureCode() (string, error) {
	n, err := rand.Int(rand.Reader, big.NewInt(1000000))
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("%06d", n.Int64()), nil
}

使用 crypto/rand 配合 math/big 的 Int() 函数,可以在不引入取模偏差的情况下生成指定范围的均匀分布数字。

常见错误清单

  1. 混用随机包:用 fmt.Sprintf("%d", rand.Int()) 做会话 ID,攻击者几分钟内就能预测下一个 Token。
  2. Token 长度过短:6 位数字验证码的组合空间仅有一百万种,必须配合过期时间和错误次数限制。
  3. 可预测的种子:使用时间戳纳秒做种子在精确时序攻击下仍可能被预测。
  4. 重用随机字节:将同一个随机字节同时用于 Token 和盐值,打破了隔离性。
  5. 忽略错误返回值:crypto/rand.Read() 在极端情况下(如 /dev/urandom 不可读)也可能失败。

安全设计原则

  • Token 长度:会话 Token 至少 16 字节(128 位),安全密钥至少 32 字节(256 位)。
  • 过期控制:验证码必须在 5~15 分钟内过期。
  • 次数限制:同一手机号或 IP 在 1 小时内最多请求 3~5 次验证码。
  • 一次性使用:验证码验证通过后,无论正确与否都应从服务端删除。
  • 安全传输:Token 应通过 HTTPS 传输,避免 HTTP 明文传递。

模拟抽奖系统实战案例

以下是一个完整的、带权重的抽奖系统示例,它综合使用了 math/rand:

package main

import (
	"fmt"
	"math/rand"
	"time"
)

type Prize struct {
	Name   string
	Weight int // 权重
}

// WeightedDraw 根据权重随机抽取一个奖品
func WeightedDraw(prizes []Prize, r *rand.Rand) Prize {
	total := 0
	for _, p := range prizes {
		total += p.Weight
	}

	point := r.Intn(total)
	cumulative := 0
	for _, p := range prizes {
		cumulative += p.Weight
		if point < cumulative {
			return p
		}
	}
	return prizes[len(prizes)-1]
}

func main() {
	prizes := []Prize{
		{Name: "iPhone", Weight: 1},
		{Name: "优惠券", Weight: 50},
		{Name: "谢谢参与", Weight: 949},
	}

	r := rand.New(rand.NewSource(time.Now().UnixNano()))

	// 运行 1000 次抽奖统计
	stats := make(map[string]int)
	for i := 0; i < 1000; i++ {
		p := WeightedDraw(prizes, r)
		stats[p.Name]++
	}

	for name, count := range stats {
		fmt.Printf("%s: %d 次 (%.1f%%)\n", name, count, float64(count)/10)
	}
}

这个实战案例展示了如何利用权重分布和固定种子的可复现性来设计公平的抽奖逻辑。

测试中的随机数最佳实践

测试中使用随机数时,核心原则是可复现性。如果测试失败是由随机数据导致的,你应该能够用完全相同的随机数据集再次触发失败。实现方法是始终为测试环境提供固定种子:

func TestPickName(t *testing.T) {
	// 固定种子保证每次测试结果一致
	r := rand.New(rand.NewSource(42))

	names := []string{"Alice", "Bob", "Charlie"}
	name, err := PickName(names, r)
	if err != nil {
		t.Fatal(err)
	}

	// 由于种子固定,expected 可以硬编码
	expected := names[1]
	if name != expected {
		t.Errorf("got %s, expected %s", name, expected)
	}
}

如果你需要测试随机性本身的分布特性(例如权重抽奖在 10000 次后的大致分布),使用固定大样本测试会比依赖随机种子更可靠。

FAQ 与常见问题排查

Q1: crypto/rand 会阻塞吗?

在 Linux 系统上,crypto/rand 默认读取 /dev/urandom,它是非阻塞的;而如果使用 /dev/random,则可能在熵池不足时阻塞。Go 的 crypto/rand 不会阻塞,可以放心在 HTTP handler 中使用。

Q2: Token 长度选多少合适?

  • 会话 Token:至少 16 字节(128 位)
  • API 密钥:至少 24 字节(192 位)
  • 密码重置链接:至少 32 字节(256 位)

Q3: math/rand 的并发安全吗?

全局 rand 函数是加锁的,可以并发使用,但有性能瓶颈。创建独立的 *rand.Rand 实例并使用各自的 Source 可以避免锁竞争。

Q4: 如何验证 Token 是否足够随机?

可以通过 ent 命令行工具或 Go 的 crypto/rand 测试来估算熵值。对于生产环境,建议使用经过审计的库,而非自己实现随机算法。

Q5: rand.Seed(1) 为什么不是随机的?

Go 的 math/rand 是伪随机数生成器。种子决定了整个数字序列。如果使用相同的种子,无论运行多少次,都会得到完全相同的序列。这既是测试的优点,也是安全场景的最大弱点。

拒绝采样法:消除取模偏差

当你需要从有限的字符集中生成安全随机字符串时,简单的取模操作(randByte % charSetLen)会引入分布偏差。例如字符集长度为 62 时,256 种可能的字节值无法均匀映射到 62 个字符上——某些字符出现的概率会略微偏高。

正确的做法是使用拒绝采样法:跳过那些会导致偏差的字节值,直到得到一个可以均匀映射的值。

package main

import (
	"crypto/rand"
	"fmt"
)

// RejectionSamplePassword 使用拒绝采样法生成无偏差的随机密码
func RejectionSamplePassword(length int) (string, error) {
	const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
	const maxByte = 256 - (256 % len(chars)) // 256 - 4 = 252

	b := make([]byte, length)
	for i := 0; i < length; {
		buf := make([]byte, 1)
		if _, err := rand.Read(buf); err != nil {
			return "", err
		}
		// 拒绝会导致偏差的值
		if int(buf[0]) >= maxByte {
			continue
		}
		b[i] = chars[int(buf[0])%len(chars)]
		i++
	}
	return string(b), nil
}

func main() {
	pwd, err := RejectionSamplePassword(20)
	if err != nil {
		panic(err)
	}
	fmt.Printf("安全密码: %s\n", pwd)
}

拒绝采样法只丢弃了约 1.5% 的字节值(在 62 字符集的情况下),性能损失极小,但获得了完美的均匀分布。如果你的场景对随机性均匀性有严格要求(例如加密密钥派生),应该始终使用这种方法。

UUID 与随机数的关系

UUID v4 本质上就是由 122 位随机数据构成的标识符。Go 标准库没有内置 UUID 包,但核心原理非常简单,你完全可以自己生成:

package main

import (
	"crypto/rand"
	"fmt"
)

// GenerateUUIDv4 生成 UUID v4 格式字符串
func GenerateUUIDv4() (string, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	// 设置版本(4)和变体位
	b[6] = (b[6] & 0x0f) | 0x40
	b[8] = (b[8] & 0x3f) | 0x80

	return fmt.Sprintf("%x-%x-%x-%x-%x",
		b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}

UUID v4 的主要优势在于其 122 位的熵空间足够大,碰撞概率极低,适合作为全局唯一标识。但请注意,UUID v4 本身不提供认证功能,不应直接作为会话 Token 使用——除非配合签名或哈希。实际生产环境中,推荐使用 github.com/google/uuid 等经过严格测试的库,而非自行实现。

安全 Token 生成 vs 伪随机数生成对比表

特性math/randcrypto/rand
随机来源确定性算法操作系统熵池
速度极快(~1.5ns/op)较慢(~50ns/op)
不可预测性无密码学级别
并发性能独立实例最好安全(无锁)
测试复现固定种子即可无法复现
适用场景模拟、测试、抽样Token、密钥、密码

总结

Go 中的 math/rand 和 crypto/rand 分别解决截然不同的两类问题。math/rand 适合测试、模拟、抽样、权重分配、非安全业务随机,具有极高的性能;crypto/rand 是安全 Token、密钥、密码生成的不二之选,具有密码学级别的不可预测性。最常见的错误是将两者混用或在安全场景下使用伪随机数。编写高质量的 Go 随机数代码,关键在于理解场景、选择正确的包、控制种子保证可复现性,以及在设计 Token 时充分考虑长度、过期和频率限制。对于要求均匀分布的安全字符串生成,记得使用拒绝采样法消除取模偏差。

性能对比与基准测试

理解 Go 随机数入门 的最佳方式是通过基准测试观察实际行为。下面是一个基本的测试框架:

func BenchmarkMain(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // 你的核心操作
        _ = i
    }
}

运行 go test -bench=. -benchmem 可以得到每个操作的耗时和内存分配数据。对比不同实现时,建议固定输入规模,跑多次取平均值。机器负载、CPU 频率和缓存状态都会影响结果,所以重要的优化应该在稳定环境中反复验证。

常见错误与最佳实践

错误一:性能优化过早

很多初学者在代码刚写好就开始担心性能,结果引入了不必要的复杂度。正确的做法是先用清晰的写法实现功能,在性能问题真实出现时再通过 profile 定位热点,再针对性优化。

错误二:忽略边界条件

空输入、超大输入、并发场景、系统资源耗尽等边界条件往往是 bug 的来源。写代码时养成习惯:每个函数都问自己,空值怎么办?错误怎么处理?资源泄漏有没有可能?

错误三:错误处理不完整

Go 的错误处理要求显式检查。常见问题是只在最外层处理错误,中间层把 error 吞掉或转换后丢失了上下文。使用 fmt.Errorf 配合 %w 保留原始错误链,上层可以用 errors.Is 判断。

错误四:并发代码缺少同步

Go 的并发模型很简洁,但共享内存访问必须同步。不要凭感觉认为"这里应该不会并发访问"就省略锁或原子操作。用 go test -race 验证并发安全性。

生产环境注意事项

生产环境的代码比本地开发要求更高。以下是一些通用原则:

  1. 日志要克制:不要记录敏感信息,不要在热路径上打印大量日志。日志的目的是排查问题,不是记录所有细节。
  2. 超时和取消:所有外部调用都要有超时。使用 context.WithTimeout 或 context.WithDeadline,不要依赖默认的无限等待。
  3. 资源限制:限制请求体大小、并发连接数、内存使用。不要让客户端决定你的资源消耗。
  4. 优雅关闭:http.Server 要设置 Shutdown 超时,goroutine 要有退出机制,channel 要有容量和关闭策略。
  5. 可观测性:至少记录关键指标(QPS、延迟、错误率)。没有指标的服务就像黑箱,出了问题只能靠猜。

测试策略

好的测试应该覆盖正常路径、错误路径和边界条件。表驱动测试是 Go 社区推荐的方式:

func TestExample(t *testing.T) {
    tests := []struct {
        name string
        input string
        want  string
    }{
        {"valid", "hello", "HELLO"},
        {"empty", "", ""},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := strings.ToUpper(tt.input)
            if got != tt.want {
                t.Fatalf("ToUpper(%q) = %q, want %q", tt.input, got, tt.want)
            }
        })
    }
}

测试不是写完就扔,每次修改代码后都要跑一遍。CI 中集成 go test ./... 是最基本的自动化保障。

实战 FAQ

Q: 这个功能在旧版 Go 中能用吗?
A: 需要看具体功能引入的版本。建议使用最新的稳定版 Go,以获得最佳工具链支持和标准库能力。

Q: 第三方库更好还是标准库更好?
A: 能标准库解决先用标准库。第三方库引入依赖成本和许可证风险。只有在标准库确实无法满足需求时才引入。

Q: 写测试时发现代码难测怎么办?
A: 这通常意味着代码耦合度太高。考虑把大函数拆成小函数,把外部依赖抽象成接口,把全局状态改成参数传递。好的代码往往是好测的代码。

Q: 怎么判断代码算不算过度设计?
A: 问自己几个问题:这个抽象让调用方更简单了吗?减少了多少重复代码?维护成本是增加还是减少了?如果答案不确定或是否定的,那可能就是过度设计。

小结

Go 随机数入门 是 Go 开发中非常实用的技能。掌握它不仅能解决当前问题,更能建立正确的编程习惯和思维方式。关键不是记住所有 API,而是理解背后的设计原则和适用边界。

在实际项目中,先让代码工作起来,再让它正确,最后才考虑让它更快。清晰的代码比聪明的代码更有价值。遇到问题时先看文档和源码,再查社区经验,最后才考虑引入新依赖。保持克制和好奇心,你的 Go 代码会越来越稳。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练