Go随机数与UUID生成原理与避坑指南
作者:XMYX-0
在日常 Go 开发中,我们几乎一定会遇到这两类需求:
- 生成随机数(验证码、抽奖、负载均衡、测试数据)
- 生成唯一 ID(订单号、请求链路 ID、数据库主键)
很多人会觉得:
“随机数不就是 rand.Intn() 吗?UUID 不就是调库生成一下?”
但实际上:
- 随机数有“伪随机”和“真随机”
- UUID 有不同版本
- 错误使用随机种子可能导致严重线上事故
- UUID 在数据库中的性能可能非常差
- 并发环境下的随机源设计非常关键
这篇文章,我们不仅讲“怎么用”,更讲:
Go 为什么这样设计?背后的本质是什么?
核心概念
随机数到底解决什么问题?
随机数的核心目标:
在“不确定性”中生成可用的数据。
常见场景:
| 场景 | 示例 |
|---|---|
| 安全领域 | Token、密码、JWT Secret |
| 业务领域 | 验证码、抽奖 |
| 系统领域 | 负载均衡、随机退避 |
| 测试领域 | Mock 数据 |
但很多开发者忽略一个问题:
“随机”其实有不同等级。
Go 中的随机数本质
Go 里主要有两套随机系统:
| 包 | 类型 | 用途 |
|---|---|---|
math/rand | 伪随机 | 普通业务 |
crypto/rand | 真随机(密码学安全) | 安全场景 |
它们最大的区别:
| 对比项 | math/rand | crypto/rand |
|---|---|---|
| 是否可预测 | 可以 | 很难预测 |
| 性能 | 快 | 较慢 |
| 是否安全 | 不安全 | 安全 |
| 是否依赖种子 | 是 | 否 |
小结
随机数并不是真的“随机”。
很多随机算法,本质上是:
“根据一个初始状态不断推导下一个值。”
这个初始状态,就是 Seed(种子)。
UUID 又是什么?
UUID(Universally Unique Identifier):
全球唯一标识符。
典型格式:
550e8400-e29b-41d4-a716-446655440000
UUID 的目标:
- 不依赖数据库自增
- 分布式唯一
- 不依赖中心节点
这对于微服务、分布式系统非常重要。
基础使用示例
注意:
在 Go1.20 之前,
math/rand 需要手动调用:
rand.Seed(time.Now().UnixNano())
否则每次程序启动生成的随机序列都相同。
而从 Go1.20 开始,
Go 已默认自动初始化随机种子,
因此即使不手动 Seed,
随机结果也会不同。
不过在工程实践中,
仍推荐使用 rand.New + rand.NewSource
创建独立随机源,
避免污染全局随机状态。
使用 math/rand 生成随机数
package main
import (
"fmt"
"math/rand"
"time"
)
func main() {
// 使用当前时间作为随机种子
rand.Seed(time.Now().UnixNano())
// 生成 0~99 的随机数
number := rand.Intn(100)
fmt.Println(number)
}
你可以理解成:
当前时间 ↓ 作为 seed ↓ rand 根据 seed 推导 ↓ 生成随机数
为什么必须 Seed?
如果你不设置种子:
package main
import (
"fmt"
"math/rand"
)
func main() {
fmt.Println(rand.Intn(100))
}
你会发现(概率性重复):
81 87 47 ...
每次程序启动结果都一样。
因为默认 Seed 是固定值。
小结
math/rand = 用 seed 推导随机序列
而:
rand.Seed(time.Now().UnixNano())
作用就是:
让每次程序运行时的 seed 都不同
这样随机结果才不同。
使用 UUID
Go 中最常见的是:
github.com/google/uuid
# 初始化 Go Module go mod init demo # 下载 uuid 依赖 go get github.com/google/uuid # 运行程序 go run main.go
示例:
package main
import (
"fmt"
"github.com/google/uuid"
)
func main() {
id := uuid.New()
fmt.Println(id.String())
}
输出:
3f4f3f1c-cdb5-4d84-9b58-67b0d4e2c1b7
进阶使用示例
使用 crypto/rand 生成安全 Token
很多人错误地用 math/rand 生成登录 Token:
token := fmt.Sprintf("%d", rand.Int())
这是极其危险的。
正确做法:
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
func main() {
// 生成随机字符串
buffer := make([]byte, 16) // 16字节的随机字符串
_, err := rand.Read(buffer) // 填充随机数据
if err != nil {
panic(err)
}
// 将随机字节转换为16进制字符串
token := hex.EncodeToString(buffer)
fmt.Println(token)
}
输出:
a215d4d030547da49fbba73fa1d71dfb
为什么安全?
因为:
- 数据来自操作系统随机源
- Linux 通常来自
/dev/urandom - 无法通过 seed 推导
这才是真正意义上的“不可预测”。
生成指定范围随机字符串
很多业务都需要:
- 随机验证码
- 邀请码
- 短链 Key
示例:
package main
import (
"fmt"
"math/rand"
"time"
)
// 生成一个随机字符串
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // 定义字符集
// 生成一个随机字符串
func generateRandomString(length int) string {
rand.Seed(time.Now().UnixNano()) // 初始化随机数生成器,确保每次运行结果不同
result := make([]byte, length) // 创建一个长度为length的byte切片
// 填充切片随机选择字符
for i := range result {
result[i] = letters[rand.Intn(len(letters))]
}
return string(result)
}
func main() {
fmt.Println(generateRandomString(8)) // 生成一个长度为8的随机字符串
}
这里其实有坑
这个代码虽然能运行:
但每次调用都重新 Seed。
高并发下可能生成重复字符串。
核心问题是:
- 每次函数调用都
Seed - 高并发下
time.Now().UnixNano()可能相同 - 导致随机序列“重置”
- 最终可能生成重复字符串
本质上是:
❗ 每次都在“从同一个起点重新随机”
解决办法:
独立随机源(更工程化)
var r = rand.New(
rand.NewSource(time.Now().UnixNano()),
)
func generateRandomString(length int) string {
result := make([]byte, length)
for i := range result {
result[i] = letters[r.Intn(len(letters))]
}
return string(result)
}
一句话点睛
❗ 随机数真正的坑,不是“不够随机”,而是“不断重置随机起点”。
基于 UUID 的订单号设计
很多系统直接使用 UUID 作为订单号:
550e8400-e29b-41d4-a716-446655440000
问题:
- 太长
- 不可读
- 数据库索引性能差
更合理的做法:
时间戳 + 随机数
示例:
package main
import (
"fmt"
"math/rand"
"time"
)
// 创建独立随机源
var r = rand.New(
rand.NewSource(time.Now().UnixNano()), // 使用当前纳秒时间戳作为种子
)
// 生成订单ID的函数
func generateOrderID() string {
now := time.Now().Unix() // 获取当前时间戳(秒级)
randomPart := r.Intn(100000) // 生成一个0到99999之间的随机数
return fmt.Sprintf("%d%05d", now, randomPart) // 将时间戳和随机数格式化为字符串,随机数部分补零到5位
}
func main() {
orderID := generateOrderID()
fmt.Println("生成的订单ID:", orderID)
}
小结
UUID 的优势:
- 唯一性强
- 分布式友好
UUID 的缺点:
- 长度大
- 索引离散
- 不适合聚簇索引
所以:
“唯一”并不代表“适合数据库”。
常见错误与坑(重点)
坑一:安全场景使用 math/rand
错误代码
resetToken := fmt.Sprintf("%d", rand.Int())
为什么危险?
攻击者只要知道:
- 随机算法
- Seed 范围
就能推测生成结果。
这在:
- 密码重置
- Session Token
- 验证码
场景中是严重漏洞。
正确写法
package main
import (
cryptoRand "crypto/rand" // crypto/rand 更安全
"encoding/hex"
"fmt"
)
func main() {
buffer := make([]byte, 32) // 创建一个长度为32的byte数组
_, err := cryptoRand.Read(buffer) // 生成随机数
if err != nil {
panic(err)
}
resetToken := hex.EncodeToString(buffer) // 将byte数组转换为16进制字符串
fmt.Println(resetToken) // 打印结果
}
思考点
为什么密码学随机数更慢?
因为:
- 需要系统熵池
- 需要不可预测
- 需要抵抗推导攻击
它追求的是“安全”,而不是“性能”。
坑二:UUID 作为 MySQL 主键
错误设计
id CHAR(36) PRIMARY KEY
为什么性能差?
UUID v4 完全随机。
会导致:
- B+Tree 插入离散
- 页分 裂频繁
- 索引碎片严重
数据库性能会越来越差。
更合理方案
可以使用:
- Snowflake
- UUID v7
- 时间有序 ID
小结
数据库最喜欢:
递增 ID
数据库最讨厌:
完全随机 ID
底层原理解析(核心)
math/rand 的本质
Go 的 math/rand:
本质是:
伪随机数生成器(PRNG)
核心逻辑:
next = f(previous)
即:
下一个随机数 = 当前状态经过算法计算
crypto/rand 的本质
crypto/rand:
不自己实现随机算法。
它依赖:
- Linux 熵池
- 内核随机设备
- CPU 随机指令
本质:
从操作系统获取不可预测的数据。
Linux 熵池
系统会收集:
- 鼠标移动
- 网络抖动
- 磁盘 IO
- CPU 时间差
形成 entropy(熵)。
然后生成随机数据。
思考点
为什么随机数和“熵”有关?
因为:
随机的本质,是“不确定性”。
熵越高:
- 越不可预测
- 越安全
UUID 的底层结构
UUID v4:
122 bit 随机数 + 版本信息
格式:
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
其中:
4表示 v4y表示变体位
为什么 UUID 能全球唯一?
因为:
2^122
空间极其巨大。
碰撞概率低到几乎可以忽略。
对比与扩展
math/rand vs crypto/rand
| 对比 | math/rand | crypto/rand |
|---|---|---|
| 类型 | 伪随机 | 真随机 |
| 性能 | 高 | 低 |
| 是否安全 | 否 | 是 |
| 是否可预测 | 是 | 否 |
| 是否需要 Seed | 是 | 否 |
UUID v1 vs v4 vs v7
| 版本 | 特点 | 问题 |
|---|---|---|
| v1 | 时间 + MAC 地址 | 泄露机器信息 |
| v4 | 完全随机 | 数据库性能差 |
| v7 | 时间有序 | 更适合数据库 |
为什么 UUID v7 越来越流行?
因为它兼顾:
- 唯一性
- 时间有序
- 数据库友好
这是现代分布式系统的重要趋势。
最佳实践
普通业务随机数
直接使用:
math/rand
适合:
- 抽奖
- 随机展示
- 测试数据
安全场景
必须使用:
crypto/rand
包括:
- Token
- 密码
- 验证码
- 密钥
Seed 只初始化一次
推荐:
func init() {
rand.Seed(time.Now().UnixNano())
}
不要:
- 重复 Seed
- 并发 Seed
UUID 不要无脑做主键
优先考虑:
- Snowflake
- UUID v7
- 自增 ID
尤其 MySQL InnoDB。
封装统一随机组件
工程中建议:
random/
math.go
crypto.go
uuid.go
统一:
- 随机策略
- Token 生成
- UUID 管理
避免团队乱用。
思考与升华
很多开发者理解随机数:
随机 = 不可预测
但计算机本质是:
确定性机器
它实际上很难真正随机。
所以:
math/rand是“算法模拟随机”crypto/rand是“利用现实世界的不确定性”
这就是两者设计哲学的根本区别。
一个简化版 PRNG 实现
package main
import "fmt"
type MyRand struct {
seed int
}
func (r *MyRand) Next() int {
r.seed = (r.seed*1103515245 + 12345) & 0x7fffffff
return r.seed
}
func main() {
r := MyRand{seed: 1}
for i := 0; i < 5; i++ {
fmt.Println(r.Next())
}
}
你会发现:
- 结果“看起来随机”
- 但其实完全确定
这就是伪随机的本质。
点睛总结
随机数与 UUID 的核心,不是“生成一个值”。
而是:
在“唯一性”、“性能”、“安全性”、“可预测性”之间做权衡。
真正成熟的 Go 开发者:
不会只会调用 API。
而会思考:
- 为什么要这样设计?
- 为什么安全随机更慢?
- 为什么 UUID 会影响数据库?
- 为什么伪随机依赖 Seed?
因为:
工程世界里,所有“随机”的背后,其实都是“设计”。
以上就是Go随机数与UUID生成原理与避坑指南的详细内容,更多关于Go随机数与UUID生成的资料请关注脚本之家其它相关文章!
