登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go crypto/aes 怎样减少临时分配

来源:17golang原创

时间:2026-11-05 23:03:05 402浏览 收藏

如果 Go 服务在循环里反复调用 crypto/aes,减少临时分配的关键不是把 AES 算法“改写得更底层”,而是把不随消息变化的对象移出热路径,并把 Seal/Open 的结果写回已有容量。最实用的组合是:aes.NewCipher 和 cipher.NewGCM 只初始化一次;每条消息复用目标切片;nonce 每次重新填充,不能因为复用内存而复用 nonce。

官方资料:https://pkg.go.dev/crypto/aes

下面的示例只讨论分配与生命周期,不给出脱离机器和输入规模的“减少了多少百分比”结论。真正的收益要用项目自己的基准和 testing.AllocsPerRun 观察。

先找出临时分配来自哪里

aes.NewCipher 接受 16、24 或 32 字节密钥,分别对应 AES-128、AES-192 和 AES-256;它返回一个 cipher.Block。把这个调用放在每条消息的循环里,会重复创建同一份轮密钥状态。随后每次调用 cipher.NewGCM 也会重复构造 AEAD 包装对象。

// 不推荐:每条消息都重新创建 block 和 AEAD。
func encryptBad(key, plaintext []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		// 密钥长度错误时直接返回,不让后续逻辑使用空对象。
		return nil, err
	}
	aead, err := cipher.NewGCM(block)
	if err != nil {
		// 保留 AEAD 构造错误,避免把配置问题伪装成加密失败。
		return nil, err
	}
	nonce := make([]byte, aead.NonceSize())
	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
		// 随机数读取失败时不能继续使用未填满的 nonce。
		return nil, err
	}
	return aead.Seal(nil, nonce, plaintext, nil), nil
}

这段代码还有一个容易忽略的分配点:Seal(nil, ...) 没有提供可复用的目标容量,结果通常需要创建新的后端数组。它适合演示 API,不适合作为高频批处理的默认结构。

把 NewCipher 和 NewGCM 移出热路径

可以把二者封装在一个长期存活的对象中。对象本身不应该被每个 goroutine 随意修改;如果多个请求共享它,通常只读地共享 cipher.AEAD,把每次请求的 nonce、输入和输出放在请求自己的状态里。

type AESGCM struct {
	aead cipher.AEAD
}

func NewAESGCM(key []byte) (*AESGCM, error) {
	// 只在配置加载或对象创建时建立 AES 状态。
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	aead, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	return &AESGCM{aead: aead}, nil
}

这样做的收益是减少重复初始化,但它不等于“所有缓冲区都能全局共享”。密文、明文和 nonce 都可能被并发请求同时使用,应当按请求、连接或 goroutine 的生命周期管理,不能把同一个可写 slice 直接交给多个并发调用。

用 Seal 的 dst 复用输出容量

cipher.AEAD.Seal 会把密文追加到 dst 并返回更新后的 slice。官方接口还明确说明:如果不再需要明文,可以用 plaintext[:0] 复用明文的后端数组承接密文;如果想保留明文,就准备一个容量足够的独立输出缓冲区。

Go crypto aes 和 cipher AEAD 复用输出缓冲区的关系说明图
图1:说明图展示 NewCipher、NewGCM 与 Seal 的复用关系;这是静态结构图,不是运行截图。
func (g *AESGCM) EncryptInto(dst, nonce, plaintext, aad []byte) ([]byte, error) {
	if len(nonce) != g.aead.NonceSize() {
		// 先检查长度,避免把调用错误带到加密内部。
		return nil, fmt.Errorf("nonce 长度为 %d,需要 %d", len(nonce), g.aead.NonceSize())
	}
	// 调用方保证 dst 的剩余容量足够,Seal 只追加密文和认证标签。
	return g.aead.Seal(dst, nonce, plaintext, aad), nil
}

func encryptReuse(g *AESGCM, nonce, plaintext []byte) ([]byte, error) {
	// plaintext 后续不再使用时,复用它的后端数组作为输出。
	return g.EncryptInto(plaintext[:0], nonce, plaintext, nil)
}

这里的 plaintext[:0] 不是复制,也不是清空底层字节;它只是把长度改成 0,保留原有容量。前提是调用者从这一刻起不再读取或修改原明文。若业务还需要记录明文摘要或重试,就应改用独立的 make([]byte, 0, len(plaintext)+g.aead.Overhead()),而不是强行复用。

nonce 可以复用存储,不能复用值

减少分配时可以把固定长度的 nonce slice 放在请求状态里,每次从 crypto/rand.Reader 重新填满它。复用的是字节数组的存储,不是 nonce 的内容。对于同一个密钥,GCM 的 nonce 必须保持唯一;把上一次的 nonce 原样传给下一次,属于安全性错误,不是性能优化。

type EncryptState struct {
	nonce []byte
	out   []byte
}

func NewEncryptState(aead cipher.AEAD, maxPlaintext int) *EncryptState {
	// nonce 长度固定;out 预留认证标签所需的额外容量。
	return &EncryptState{
		nonce: make([]byte, aead.NonceSize()),
		out:   make([]byte, 0, maxPlaintext+aead.Overhead()),
	}
}

func (s *EncryptState) Seal(aead cipher.AEAD, plaintext, aad []byte) ([]byte, error) {
	// 每次调用都覆盖 nonce,不能把上一次的 nonce 直接重复使用。
	if _, err := io.ReadFull(rand.Reader, s.nonce); err != nil {
		return nil, err
	}
	// 只复用 out 的容量;返回值可能是扩容后的新 slice,要接住它。
	s.out = s.out[:0]
	s.out = aead.Seal(s.out, s.nonce, plaintext, aad)
	return s.out, nil
}

如果状态对象会被多个请求并发使用,就不能共享这里的 nonce 和 out。可以用对象池、每请求状态或每个 worker 的本地状态来隔离写入;对象池归还前还要明确调用方不再持有返回 slice。

解密时用 ciphertext[:0] 回收同一块存储

Open 与 Seal 的目标参数同样是追加目标。密文已经被读取完、调用方不再需要保留它时,可以把 ciphertext[:0] 作为目标,原地承接解出的明文。认证失败时必须检查错误;即便失败,目标容量范围内的内容也可能被覆盖,不能继续把旧明文当作可靠结果。

Go AEAD Seal Open 复用切片和 nonce 安全边界说明图
图2:结构图展示 Seal/Open 的安全复用路径,以及 nonce 重复和切片重叠的禁止边界;这是静态说明图,不是运行截图。
func decryptReuse(aead cipher.AEAD, ciphertext, nonce, aad []byte) ([]byte, error) {
	// ciphertext 后续不再使用,才可以把它的存储作为 Open 的 dst。
	plaintext, err := aead.Open(ciphertext[:0], nonce, ciphertext, aad)
	if err != nil {
		// 认证失败时丢弃返回 slice,不能读取其中可能被覆盖的内容。
		return nil, err
	}
	return plaintext, nil
}

复用边界要记住三条:dst 不能与 additionalData 重叠;目标剩余容量不能以不允许的方式与输入重叠;nonce 和附加数据必须与加密时的语义匹配。省下一次分配,不值得换来难以定位的数据别名问题。

用 AllocsPerRun 观察,而不是凭感觉改代码

建议先保留一个清晰的基线,再比较“重复初始化 + nil 输出”和“复用 AEAD + 预留输出”的版本。基准只用于观察当前机器、Go 版本和输入大小下的分配趋势,不应把一次运行的数字写成通用承诺。

func BenchmarkEncryptReuse(b *testing.B) {
	key := bytes.Repeat([]byte{0x42}, 32)
	plaintext := bytes.Repeat([]byte("payload-"), 32)
	aead, _ := newAEAD(key)
	nonce := make([]byte, aead.NonceSize())
	out := make([]byte, 0, len(plaintext)+aead.Overhead())

	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i 

上面的基准为了突出切片行为,使用了固定 nonce,不能直接拿作生产加密代码。生产路径必须在每次消息加密时获得唯一 nonce;可以把“随机填充 nonce”和“Seal 的分配”分成两个基准,避免把安全必需的成本误判成可删除的临时分配。

什么时候不要强行复用

  • 明文需要在加密后继续使用、异步发送或写入审计队列时,不要用 plaintext[:0] 覆盖它。
  • 密文需要重试、缓存或同时发往多个下游时,不要用 ciphertext[:0] 作为解密目标。
  • 多个 goroutine 共享同一个可写状态时,不要为了少一次 make 而共享输出和 nonce。
  • 输入长度变化很大时,长期保留一个极大的容量可能增加内存占用;可以按请求大小或 worker 生命周期重新规划缓冲区。

可以把最终取舍概括为:复用对象,复用容量,重新生成 nonce;先确保数据生命周期和并发边界正确,再用基准确认分配确实是瓶颈。

相关问题

aes.NewCipher 是否应该每次加密都调用?

不应该。密钥不变时,把它和 cipher.NewGCM 放在初始化阶段;每次调用只传入新的 nonce、明文、附加数据和目标缓冲区。

为什么 Seal 的目标 slice 需要接住返回值?

当剩余容量不足时,Seal 可能扩容并返回新的后端数组。即使你预先复用了容量,也应该写成 dst = aead.Seal(dst, ...)。

nonce 复用会不会只是影响性能?

不会。对同一个密钥重复使用 GCM nonce 是安全性错误。可以复用 nonce slice 的存储,但每条消息都必须填入新的唯一值。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>