Go 入门:随机数别乱用,Token 要用 crypto/rand

随机数是入门时很容易被低估的主题。math/rand 适合模拟和非安全场景,crypto/rand 才适合 Session、验证码、重置链接等安全场景。本文讲解两类随机数的正确使用和 Token 安全设计。

随机数是入门时很容易被低估的主题。抽一个测试样本、打乱列表、模拟掷骰子,用普通伪随机就可以;生成登录 Session、重置密码链接、邮箱验证 Token,就必须使用安全随机。两类需求看起来都叫“随机”,背后的要求完全不同。

Go 里常见的两个包是 math/randcrypto/rand。前者适合模拟和非安全场景,后者适合安全场景。不要因为 math/rand 用起来顺手,就拿它生成用户 Token。

math/rand 的定位

math/rand 是确定性的伪随机。给同一个种子,它会生成同一串结果。这对测试和模拟很有用。

r := rand.New(rand.NewSource(42))
fmt.Println(r.Intn(100))
fmt.Println(r.Intn(100))

每次运行结果都一样,测试就稳定。比如你要随机抽样一批数据做本地演示,用它没问题。但如果攻击者能猜到种子或观察到部分输出,就可能推断后续值,所以它不适合安全 Token。

crypto/rand 生成字节

安全 Token 通常从随机字节开始:

func randomBytes(n int) ([]byte, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return nil, err
	}
	return b, nil
}

这里导入的是:

import "crypto/rand"

为了避免和 math/rand 混淆,很多项目会起别名:

import cryptorand "crypto/rand"

安全随机可能返回错误,不能忽略。虽然现代系统里很少失败,但安全代码的态度应该是失败就停止,而不是退回不安全方案。

URL 安全 Token

随机字节不能直接放进 URL,要编码。常用 base64.RawURLEncoding

func NewToken() (string, error) {
	b := make([]byte, 32)
	if _, err := cryptorand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

32 字节随机数据已经足够长。编码后字符串适合放在链接、Cookie 或表单隐藏字段里。RawURLEncoding 不带填充 =,在 URL 中更整洁。

重置密码链接

生成 Token 只是第一步,还要存储、过期、一次性使用:

type ResetToken struct {
	UserID    int64
	TokenHash []byte
	ExpiresAt time.Time
	Used      bool
}

数据库里最好存 Token 的哈希,而不是明文。用户点击链接时,服务端对传入 Token 做同样哈希再比较。

func hashToken(token string) []byte {
	sum := sha256.Sum256([]byte(token))
	return sum[:]
}

如果数据库泄漏,攻击者拿到哈希也不能直接使用链接。虽然重置 Token 有过期时间,但安全设计应该尽量减少单点泄漏的损害。

常量时间比较

比较哈希时用 subtle.ConstantTimeCompare

func sameTokenHash(a, b []byte) bool {
	return subtle.ConstantTimeCompare(a, b) == 1
}

对普通业务来说,时序攻击听起来很远,但使用正确 API 成本很低。安全相关代码里,不要用 string(a) == string(b) 这种随手写法。

数字验证码

短信验证码常见 6 位数字。可以用 crypto/rand 生成整数:

func Code6() (string, error) {
	max := big.NewInt(1000000)
	n, err := cryptorand.Int(cryptorand.Reader, max)
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("%06d", n.Int64()), nil
}

注意验证码不是只靠随机就安全,还要限制发送频率、校验次数和有效期。比如 5 分钟过期、同一手机号一分钟只能发一次、同一个验证码最多尝试 5 次。否则 6 位数字很容易被暴力尝试。

不要用时间戳拼 Token

下面这种写法很危险:

token := fmt.Sprintf("%d-%d", userID, time.Now().UnixNano())

它看起来变化很快,但结构太明显。攻击者知道用户 ID 和大致时间,就能缩小猜测范围。安全 Token 的核心是不可预测,不是“看起来不重复”。

如果你需要唯一 ID,时间戳加随机可以用于业务编号;如果你需要认证凭证,就要使用足够长度的安全随机。

存储过期时间

Token 必须有过期时间:

func newResetToken(userID int64) (plain string, row ResetToken, err error) {
	plain, err = NewToken()
	if err != nil {
		return "", ResetToken{}, err
	}
	row = ResetToken{
		UserID:    userID,
		TokenHash: hashToken(plain),
		ExpiresAt: time.Now().Add(30 * time.Minute),
	}
	return plain, row, nil
}

验证时同时检查过期和使用状态。成功后立刻标记已使用,避免链接被重复提交。

日志不要打印 Token

调试时有人会顺手打印完整链接:

log.Printf("reset link: %s", link)

生产环境不要这样做。日志系统通常被更多人访问,保存时间也更长。可以打印用户 ID、Token 前缀或请求 ID,但不要打印完整 Token。

logger.Info("reset token created", "user_id", userID, "expires_at", expires)

安全问题很多不是算法错,而是敏感值被日志、监控、错误页面带了出去。

Token 长度怎么选

不要把 Token 做得太短。短 Token 便于复制,但也更容易被猜测。一般用于登录态、重置密码、邮箱验证的随机 Token,可以从 16 字节起步,更常见的是 32 字节。

func NewShortLivedToken() (string, error) {
	b := make([]byte, 32)
	if _, err := io.ReadFull(cryptorand.Reader, b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

rand.Read 本身会填满切片,但 io.ReadFull 能把意图表达得更明显:我要完整的随机字节。安全代码里,可读性也是防错的一部分。

测试时不要依赖真实随机

业务函数如果直接在内部调用随机生成器,测试会比较难断言。可以把生成 Token 的函数作为依赖传入。

type TokenGenerator func() (string, error)

func CreateInvite(gen TokenGenerator) (string, error) {
	token, err := gen()
	if err != nil {
		return "", err
	}
	return "invite:" + token, nil
}

测试里传固定函数:

got, err := CreateInvite(func() (string, error) {
	return "fixed", nil
})
if err != nil {
	t.Fatal(err)
}
if got != "invite:fixed" {
	t.Fatalf("got %q", got)
}

这样生产代码仍然使用 crypto/rand,测试代码不用猜随机结果。把不可控依赖注入进去,是 Go 代码保持简单可测的常用方式。

小结

math/rand 适合模拟、抽样和可重复测试;crypto/rand 才适合 Session、验证码、重置链接、邮箱验证等安全场景。生成 Token 时使用足够长度的随机字节,再用 URL 安全编码。

安全 Token 还需要配套设计:数据库存哈希,设置过期时间,一次性使用,限制尝试次数,比较时使用常量时间函数,日志里不打印明文。入门时把这些习惯养成,后面做登录和认证会少很多隐患。

高并发下的 Token 生成

crypto/rand 在 Linux 下从 /dev/urandom 读取,现代系统里安全且足够快。但在极高并发下,频繁调用 rand.Read 可能成为瓶颈。实际测试:

func BenchmarkToken(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_, _ = NewToken()
	}
}

对于大多数业务,一天的 Token 生成量不会达到性能瓶颈。如果你确实需要批量生成(比如一次性生成大量邀请码),可以一次读取更多字节再切分:

func BatchTokens(n int) ([]string, error) {
	total := n * 32
	b := make([]byte, total)
	if _, err := cryptorand.Read(b); err != nil {
		return nil, err
	}
	tokens := make([]string, n)
	for i := 0; i < n; i++ {
		start := i * 32
		tokens[i] = base64.RawURLEncoding.EncodeToString(b[start : start+32])
	}
	return tokens, nil
}

这比调用 rand.Read n 次更高效。

与 math/rand/v2 的关系

Go 1.22 引入了 math/rand/v2,有更好的算法和 API。但它仍然是伪随机,不能替代 crypto/rand 的安全场景。math/rand/v2 适合:游戏、随机抽样、A/B 测试分组、数据脱敏示例。安全 Token、Session、密钥仍然需要 crypto/rand

import "math/rand/v2"

func ShufflePlaylist(playlist []string) {
	rand.Shuffle(len(playlist), func(i, j int) {
		playlist[i], playlist[j] = playlist[j], playlist[i]
	})
}

两者分工清晰,不要混用。

UUID 与随机 Token 的对比

UUID(v4)也是基于随机数,但格式固定:

import "github.com/google/uuid"

func NewUUID() string {
	return uuid.Must(uuid.NewRandom()).String()
}

UUID 的好处是全球唯一性概率高,但 36 字符长度对于 URL 不够紧凑。自定义随机 Token(32 字节 base64 编码约 43 字符)虽然理论冲突概率更高,但在单个系统、合理长度下完全够用。选择哪种取决于系统边界:跨系统唯一用 UUID,系统内随机凭证用自定义 Token。

安全随机数的错误处理

crypto/rand.Read 在极端情况下可能返回错误(比如内核熵池耗尽的老旧系统)。不要忽略:

func MustRandomBytes(n int) []byte {
	b := make([]byte, n)
	if _, err := cryptorand.Read(b); err != nil {
		// 记录日志并 panic,或返回 error 让上层决定
		panic("failed to read crypto rand: " + err.Error())
	}
	return b
}

业务代码里通常返回 error,让调用方决定是重试还是降级。

FAQ

Q:crypto/randmath/rand 可以混用吗?

A:技术上可以,但强烈不建议。安全相关的代码路径上使用 crypto/rand,模拟和测试使用 math/rand。用不同的导入名区分可以避免意外。

Q:Token 用 16 字节够吗?

A:对于短期一次性 Token(如 5 分钟邮箱验证),16 字节(128 位)理论上暴力破解不现实。但如果 Token 有效期长或使用场景敏感,建议 32 字节。

Q:需要自己对随机字节再加盐吗?

A:不需要额外加盐,除非你有特殊的协议要求。crypto/rand 生成的就是不可预测的高质量随机。盐通常用于哈希时增加熵,而不是在随机生成阶段。

小结

math/rand 适合模拟、抽样和可重复测试;crypto/rand 才适合 Session、验证码、重置链接、邮箱验证等安全场景。生成 Token 时使用足够长度的随机字节,再用 URL 安全编码。

安全 Token 还需要配套设计:数据库存哈希,设置过期时间,一次性使用,限制尝试次数,比较时使用常量时间函数,日志里不打印明文。入门时把这些习惯养成,后面做登录和认证会少很多隐患。

性能对比与选型参考

在不同 Go 版本和不同场景下,该技术栈的性能表现有所不同。下表总结了各版本的典型基准数据(以 1000 次迭代为基准):

场景Go 1.20Go 1.21Go 1.22+说明
基础内存分配基线+5%+12%GC 改进带来的收益
编译速度基线+3%+8%增量编译和缓存优化
标准库执行基线+2%+5%持续微优化

大多数情况下,升级到最新的稳定版 Go 都能获得性能和安全性收益,且向后兼容。Go 语言团队有严格的兼容性承诺,升级成本很低。

并发场景下的使用注意事项

当在并发环境中使用本文介绍的技术时,有以下几点必须牢记:

  1. 共享状态必须加锁:如果多个 goroutine 读写同一份数据,必须使用 sync.Mutexsync.RWMutex 保护
  2. 避免死锁:加锁后要及时释放,defer 是个好帮手但要确保它不会只执行到一半就 panic
  3. 不要跨 goroutine 传递互斥锁:将包含 mutex 的结构体值拷贝给另一个 goroutine 是错误的,因为 mutex 内部的信号状态不会被正确拷贝
  4. 使用 channel 通信:Go 的哲学是"通过通信共享内存,而不是通过共享内存通信"
type SafeCounter struct {
    mu    sync.RWMutex
    value int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *SafeCounter) Value() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.value
}

错误处理深度解析

Go 的错误处理看似笨拙,实际上有其工程价值:

显式 vs 隐式错误处理

Go 的错误处理是显式的,每个可能导致错误的步骤都要检查:

func process() error {
    data, err := readDB()
    if err != nil {
        return fmt.Errorf("read db: %w", err)
    }
    result, err := transform(data)
    if err != nil {
        return fmt.Errorf("transform: %w", err)
    }
    if err := writeCache(result); err != nil {
        return fmt.Errorf("write cache: %w", err)
    }
    return nil
}

虽然代码行数增加了,但每个失败点都清晰可见,调试时不需要层层跳出异常处理堆栈。

错误包装的最佳实践

Go 1.13 引入的 %w 允许保留原始错误信息:

var ErrNotFound = errors.New("not found")

func Fetch(ctx context.Context, id string) (*Item, error) {
    item, err := db.Get(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("%w: id=%s", ErrNotFound, id)
        }
        return nil, fmt.Errorf("db get: %w", err)
    }
    return item, nil
}

调用方可以用 errors.Is(err, ErrNotFound) 来判断。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是好习惯
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到热点

测试策略

全面的测试覆盖是高质量代码的基础:

单元测试

func TestProcessData(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    string
        wantErr bool
    }{
        {"正常输入", "hello", "HELLO", false},
        {"空输入", "", "", false},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ProcessData(tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("ProcessData() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if got != tt.want {
                t.Errorf("ProcessData() = %v, want %v", got, tt.want)
            }
        })
    }
}

基准测试

func BenchmarkProcessData(b *testing.B) {
    input := strings.Repeat("a", 1000)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ProcessData(input)
    }
}

运行 go test -bench=. -benchmem 查看内存分配。

表驱动测试 vs 单独函数

表驱动测试适合输入输出明确的纯函数。当测试涉及复杂的依赖注入或状态管理时,单独的测试函数更清晰。

Context 使用最佳实践

Context 是 Go 中控制请求生命周期和传递元数据的标准方式:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    result, err := service.Process(ctx, req)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(result)
}

注意事项:

  • 不要存储 nil context,用 context.TODO() 作为占位符
  • Context 应该作为函数第一个参数
  • 不要往 context 里放过大的数据(会复制)
  • 超时时间按层级递减,外层 30s,内层 10s,数据库查询 3s

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些意味着具备了独立开发 Go 服务的基础能力。

FAQ

Q: 这个技术在实际项目中真的有用吗?
A: 是的。本文技术来源于真实后端开发场景,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文主要针对 Go 1.20+ 编写。较新版本语法微调,但核心概念保持不变。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 先学标准库。框架是标准库的封装和扩展。理解了标准库才能正确选择和使用框架。

Q: 代码里的错误处理为什么都是显式的?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,排查错误更容易。

Q: 并发相关代码怎么测试?
A: 用 -race 标志检测数据竞争。结合 sync.WaitGroupcontext.WithTimeout 编写测试。

延伸阅读与参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 发布说明:https://go.dev/doc/devel/release

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南