【Golang】一次Golang性能优化记录

背景

公司内部项目,使用了go作为开发语言。具体功能就是将一个Redis的数据通过 PSync 协议获取RDB、AOF文件,进行解析,转换为一条条Redis命令对象 Entry 。程序整体架构采用分模块设计,分为读取端(Reader)和写入端(Writer)两个模块,数据在程序内的 ReaderWriter 流动使用 Golang 的 Channel 进行。其中 ReaderChannel 中发送数据过程涉及 RDB 解析模块、发送超时处理、接受暂停 / 恢复指令等情况。由于 RDB 模块在另外的包中,所以为了兼顾超时处理和暂停 / 恢复指令,所以在Reader内部还使用了另一个Channel来接续传递 Entry

问题

Windows 下开发时,性能问题没有凸显,由于整体吞吐量和性能还不错(OPS:35~40W),所以直接使用 Golang 的交叉编译功能,将程序直接编译成 Linux 版本上线运行。由于业务 Redis 生产和更新数据速度过快,导致数据大量堆积,且整体性能只有 Windows 下的 1/4 (OPS:10W)。

机器配置如下

除了我的CPU主频高(后面要考)之外,其他应该都是吊打我的机器才对啊ㄟ( ▔, ▔ )ㄏ

机器配置WindowsUbuntu Linux
系统版本Windows 11 Pro 23H2LTS 22.04
CPUIntel Core i5-10500Intel Xeon Gold 5218 * 2 (NUMA)
内存32 GiB128 GiB
CPU主频3.10GHz2.30GHz
网络1Gbps10Gbps

剖析问题

一开始使用 pprof 对程序的 CPU时间 进行统计,发现Linux下的 GC 时间 远大于Windows GC 时间 。除此之外还发现大量CPU时间花在了 创建对象

Linux下的 GC CPU时间(runtime.gcBgMarkWorker)
请添加图片描述

Windows下的GC CPU 时间(runtime.gcBgMarkWorker)

请添加图片描述

创建对象占很多CPU时间(runtime.mallocgc),这意味着大量的对象被创建又被 GC 销毁

请添加图片描述

解决问题

1、试试调整GC频率

使用 GOGC 环境变量(默认值100),设置其 GC 频率为堆内存增长了原本的n倍才触发GC,这样可以大幅减小GC时间,比如设置成200(当堆内存增长了原本的200%才触发GC)。如果设置过大(例如10000),单次GC时间变长,也可能导致负优化。

经过测试,当 GOGC 设置成 500 时,吞吐量达到了 20W/s。但相比Windows下的 40W/s 仍有较大的差距。

2、对耗时较大的函数进行优化

(1)序列化方法
优化点1:对重复创建的对象进行池化

经过一番查找,发现在对 Entry 命令进行序列化的时候,消耗了大量的时间。

请添加图片描述

于是对序列化方法的这几个变量进行池化操作:

// 全局内存池配置
var (
    bufferPool = sync.Pool{
        New: func() interface{} {
            return bytes.NewBuffer(make([]byte, 0, 4<<10)) // 4KB初始容量
        },
    }
    
    writerPool = sync.Pool{
        New: func() interface{} {
            return proto.NewWriter(nil) // 延迟绑定buffer
        },
    }
    
    argvPool = sync.Pool{
        New: func() interface{} {
            return make([]interface{}, 0, 8) // 预分配参数容器
        },
    }
)

func (e *Entry) Serialize() []byte {
    // 获取复用资源
    buf := bufferPool.Get().(*bytes.Buffer)
    writer := writerPool.Get().(*proto.Writer)
    argv := argvPool.Get().([]interface{})
    
    defer func() {
        // 重置并归还资源
        ...
    }()
    
    // 绑定Writer到Buffer
    writer.Reset(buf)
    
    // 复用参数容器(避免频繁slice扩容)
    if cap(argv) < len(e.Argv) {
        argv = make([]interface{}, len(e.Argv))
    } else {
        argv = argv[:len(e.Argv)]
    }
    
    ...
    
    // 执行序列化
    ...
    
    e.SerializedSize = int64(buf.Len())
    
    // 安全复制结果(避免后续修改污染池中buffer)
    result := make([]byte, buf.Len())
    copy(result, buf.Bytes())
    return result
}
优化点2:对结果切片进行引用标记

然后就发现了新的问题,51行的 result 仍需要重新分配内存,仍然会导致较大的 GC 压力。

所以我们对最终存储结果的 buffer 增加了 引用标记 功能,可选择延迟归还底层数据结构,并且可做到自归还内存池。

type BufferRef struct {
	data  []byte        // 底层数据
	Buf   *bytes.Buffer // 显式保存底层buffer的引用
	pool  *BufferPool   // 所属内存池
	inUse bool          // 使用状态标记
}

// BufferPool 自定义内存池
type BufferPool struct {
	sync.Pool
	maxSize int
}

func NewBufferPool(initSize, maxSize int) *BufferPool {
	return &BufferPool{
		Pool: sync.Pool{
			New: func() interface{} {
				data := make([]byte, 0, initSize)
				return &BufferRef{
					data:  data,
					pool:  nil,
					inUse: false,
					Buf:   bytes.NewBuffer(data[:0]),
				}
			},
		},
		maxSize: maxSize,
	}
}

// Get 获取缓冲区(自动扩容)
func (p *BufferPool) Get() *BufferRef {
	buf := p.Pool.Get().(*BufferRef)
	buf.pool = p
	buf.inUse = true
	return buf
}

// Release 释放缓冲区(延迟归还)
func (b *BufferRef) Release() {
	if !b.inUse {
		log.Panicf("double free detected!")
	}
	if cap(b.data) > b.pool.maxSize {
		return // 超大buffer直接丢弃
	}
	b.data = b.data[:0]
	b.Buf.Reset()
	b.inUse = false
	b.pool.Put(b)
}

func (b *BufferRef) Sync() {
	b.data = b.Buf.Bytes()
}

func (b *BufferRef) Bytes() []byte {
	b.Sync()
	return b.data[:len(b.data):len(b.data)] // 返回不可变切片
}

于是再经过一番改造,一个新的序列化方法就诞生了:

const (
	initBufferSize = 4 << 10  // 4KB
	maxBufferSize  = 64 << 10 // 64KB
)

// 全局内存池配置
var (
	bufPool = NewBufferPool(initBufferSize, maxBufferSize)

	writerPool = sync.Pool{
		New: func() interface{} {
			return proto.NewWriter(nil) // 延迟绑定buffer
		},
	}

	argvPool = sync.Pool{
		New: func() interface{} {
			return make([]interface{}, 0, 8) // 预分配参数容器
		},
	}
)

func (e *Entry) SerializeN() *BufferRef {
	// 获取复用资源
	buf := bufPool.Get()
	writer := writerPool.Get().(*proto.Writer)
	argv := argvPool.Get().([]interface{})

	defer func() {

		writer.Reset(nil)
		writerPool.Put(writer)

		argv = argv[:0]
		argvPool.Put(argv)
	}()

	// 绑定Writer到Buffer
	writer.Reset(buf.Buf)

	// 复用参数容器(避免频繁slice扩容)
	if cap(argv) < len(e.Argv) {
		argv = make([]interface{}, len(e.Argv))
	} else {
		argv = argv[:len(e.Argv)]
	}

	for i := 0; i < len(e.Argv); i++ {
		argv[i] = e.Argv[i]
	}

	// 执行序列化
	if err := writer.WriteArgs(argv); err != nil {
		buf.Release()
		log.Panicf("serialization error: %v", err) // 避免panic保证内存池正常回收
	}
	e.SerializedSize = int64(buf.Buf.Len())
	return buf
}

// ######################### 使用完成后,手动归还buf到池子 ############################
bytes := e.SerializeN()
w.client.SendBytes(bytes.Bytes())
bytes.Release()

最终,优化后的序列化方法得到了较大的性能提升,占用CPU时间和间接占用CPU时间(GC)降低

请添加图片描述

(2)不必要的Debug日志

项目中很多这样的日志,对发送到目的端的CMD和回复进行打印,其中 e.String() 是将 Entry 对象转换为 Redis 命令的方法。由于 Debug 日志并不常用,而且这种调用方式虽然不会打印到日志输出,但是 e.String() 还是会正常执行,在处理数据过程中占用大量的时间。

log.Debugf("[%s] receive reply. reply=[%v], cmd=[%s]", w.stat.Name, reply, e.String())

所以此处加了一个条件判断,这样就可以大幅减少不必要的日志运算和字符串处理。

log.Debugf("[%s] receive reply. reply=[%v], cmd=[%s]", w.stat.Name, reply, entryToString(e, "debug"))

func entryToString(e *entry.Entry, logType string) string {
	if logType == "debug" && config.Opt.Advanced.LogLevel == "debug" {
		return e.String()
	}
	if logType != "debug" {
		return e.String()
	}
	return ""
}

(3)提交超时和暂停/恢复功能

回顾一下背景

其中 ReaderChannel 中发送数据过程涉及 RDB 解析模块、发送超时处理、接受暂停 / 恢复指令等情况。由于 RDB 模块在另外的包中,所以为了兼顾超时处理和暂停 / 恢复指令,所以在Reader内部还使用了另一个Channel来接续传递 Entry

请添加图片描述

优化点1:定时器重复创建

此处发现 定时器 占用了太多的CPU资源,查看代码如下:

func (r *...) SubmitWithTimeout(e *entry.Entry) {
	timer := time.NewTimer(r.opts.SubmitTimeout)
	defer func() {
		if !timer.Stop() {
			select {
			case <-timer.C:
			default:
			}
		}
	}()
	select {
	case <-r.ctx.Done():
		return
	case <-utils.PPI.PauseWait():
		log.Infof("[%s] is paused", r.stat.Name)
		<-utils.PPI.ResumeWait()
		log.Infof("[%s] is resumed", r.stat.Name)
	case <-utils.PPI.StopWait():
		log.Infof("[%s] is stopped", r.stat.Name)
		close(r.ch)
	case r.ch <- e:
	case <-timer.C:
        ...panic()
	}
}

于是把定时器放到结构体对象中,使用Reset代替重复创建,优化如下:

func (r *...) SubmitWithTimeout(e *entry.Entry) {
	if r.timer == nil {
		r.timer = time.NewTimer(r.opts.SubmitTimeout)
	} else {
		r.timer.Reset(r.opts.SubmitTimeout)   // 重置定时器
	}
	defer func() {
		if !r.timer.Stop() {
			select {
			case <-r.timer.C:
			default:
			}
		}
	}()
	select {
	...
	case r.ch <- e:
	case <-r.timer.C:
		...
	}
}
优化点2:优化select耗时

然后又发现 select 多路复用器占用了太多的CPU时间,经过查找资料发现原来是 分支越多越费时间

在这里插入图片描述

于是给推送数据开了一个快速通道,这下速度是快了:

func (r *...) SubmitWithTimeout(e *entry.Entry) {
	// 快速路径
	select {
	case r.ch <- e:
		return // 写入成功,直接返回
	default:
		// 进入慢速路径处理
	}

    // 下面是原始逻辑
	....
}

在这里插入图片描述

优化点3:暂停/恢复逻辑原子化

随后新的问题又出现了:有极小概率出现无法接收暂停恢复信号,也就是说概率无法暂停恢复,于是将暂停恢复的通道逻辑开一个新的协程,通过原子布尔值来解决暂停恢复失效问题:

type ... struct {
	...
	// 暂停恢复控制信号相关
	stopOnce   sync.Once
	stopChan   chan struct{} // 用于通知停止
	paused     atomic.Bool   // 原子标记暂停状态
	resumeChan chan struct{} // 用于恢复通知
}

// 独立处理控制信号的 goroutine
func (r *...) controlSigHandler() {
	for {
		select {
		case <-utils.PPI.PauseWait():
			log.Infof("[%s] is paused", r.stat.Name)
			r.paused.Store(true)

			// 等待恢复信号
			select {
			case <-utils.PPI.ResumeWait():
				log.Infof("[%s] is resumed", r.stat.Name)
				r.paused.Store(false)
				close(r.resumeChan)                // 通知恢复
				r.resumeChan = make(chan struct{}) // 重置通道
			case <-r.stopChan:
				return
			case <-r.ctx.Done():
				return
			}
		case <-utils.PPI.StopWait():
			r.stopOnce.Do(func() {
				log.Infof("[%s] is stopped", r.stat.Name)
				close(r.stopChan)
				close(r.ch)
			})
			return
		case <-r.ctx.Done():
			return
		}
	}
}

// 新的超时提交方法
func (r *...) SubmitWithTimeout(e *entry.Entry) {
	// 快速路径:先检查原子状态标志
	if r.paused.Load() {
		goto SlowPath
	}
	select {
	case r.ch <- e:
		return // 写入成功,直接返回
	default:
		// 进入慢速路径处理
	}

SlowPath:
	... // 原定时器逻辑(略)

	// 慢速路径 主处理循环
	for {
		select {
		case <-r.ctx.Done():
			return
		case <-r.stopChan:
			return
		case <-r.resumeChan: // 等待恢复通知
			r.paused.Store(false)
		default:
			// 继续处理
		}

		// 检查暂停状态
		if r.paused.Load() {
			select {
			case <-r.resumeChan:
				r.paused.Store(false)
			case <-r.ctx.Done():
				return
			case <-r.stopChan:
				return
			}
			continue // 状态更新后重试
		}

		// 尝试写入或等待超时
		select {
		case r.ch <- e:
			return // 写入成功
		case <-r.timer.C:
            ... panic()
		case <-r.ctx.Done():
			return
		case <-r.stopChan:
			return
		}

	}
}

这样,就解决了提交超时函数占用时间过长的问题。

(4)哈希优化

若目的端为集群,则需要提前计算 CRC16 的哈希值确定Redis槽位。原本的 CRC16 函数已经是查表方式,按理来说不太可能继续优化,然后看了看代码,突然觉得我又行了。

// 原CRC16函数
func Crc16(buf string) uint16 {
	var crc uint16
	bytesBuf := []byte(buf)
	for _, n := range bytesBuf {
		crc = (crc << uint16(8)) ^ crc16tab[((crc>>uint16(8))^uint16(n))&0x00FF]
	}
	return crc
}
优化点1:去除不必要的类型转换

当前实现中,crc = (crc << uint16(8)) ^ crc16tab[((crc>>uint16(8))^uint16(n))&0x00FF]uint16(n) 的转换是多余的,因为 n 本身是 byte 类型(uint8),在进行 ^ 操作时会自动提升为 uint16

crc = (crc << 8) ^ crc16tab[(byte(crc>>8)^n)]
优化点2:避免 []byte(buf) 额外分配

实际上 Go 的 string 本身可以按字节索引,不需要额外分配 []byte,可以直接遍历 buf

func Crc16(buf string) uint16 {
	var crc uint16
	for i := 0; i < len(buf); i++ {
		crc = (crc << 8) ^ crc16tab[(byte(crc>>8)^buf[i])]
	}
	return crc
}

这样可以减少 []byte 额外的内存分配,提升性能。

优化点3:查找表换成 uint8 索引

目前 crc16tab[256]uint16,查询时索引 ((crc>>8) ^ n) & 0xFF 已经是 uint8,但 crc16tab 使用了 uint16 作为索引类型,这在 CPU 处理时可能不够高效。

可以使用 uint8 版本的 crc16tab,然后用 uint8 变量存储 crc>>8 结果:

func Crc16(buf string) uint16 {
	var crc uint16
	for i := 0; i < len(buf); i++ {
		index := byte(crc>>8) ^ buf[i]
		crc = (crc << 8) ^ crc16tab[index]
	}
	return crc
}

这样编译器可能会优化 index 计算,减少 crc 右移带来的额外操作。

优化后,节省了3%的CRC时间。

(5)大量Select操作耗时

优化到了这里,基本上已经没什么空间了,在不调整GC的情况下,已经由原本的10W OPS,提升到了近18W OPS。然后就发现了火焰图中几个扎眼的Select耗时。

在这里插入图片描述

后面经过排查,发现后面三个Select都是如下结构:

select {
case <-ctx.Done():
    return
case ld.ch <- e:   
}
// ##############################
select {
case <-ctx.Done():
    return
case e, ok := ch: 
    // 处理数据
    ...
}

都是一个检查上下文是否结束,一个是往通道推送或从通道接收数据,只有两个分支。

然后将这种Select都进行优化,变成如下形式

select {
case <-ctx.Done():
    return
default:
}
ld.ch <- e

// ##############################
select {
case <-r.ctx.Done():
    return
default:
}
e, ok := <-ch
// 处理数据
...

让主逻辑脱离 Select 的控制,减少不必要的锁控

最终得到了如下火焰图,三个select被干掉,间接地,无法被干掉的,还监听主channel的select耗时也下降了(锁竞争没那么激烈了)。

在这里插入图片描述

此时OPS来到了24W

(6)NUMA优化

优化到这里了,程序能接着优化的地方已经不多。因为服务器是双路CPU,所以我把目标转向了双路CPU NUMA机制。

以下是从DeepSeek获取的NUMA节点的通信延迟资料

NUMA(非统一内存访问)架构是为解决多处理器系统中共享内存总线瓶颈而设计的。它将系统划分为多个节点(Node),每个节点包含若干CPU核心和本地内存。节点间通过高速互连(如Intel QPI、AMD Infinity Fabric)通信。例如:

  • 双路系统:2个CPU插槽,每个CPU构成一个NUMA节点。
  • 四路系统:4个CPU插槽,形成4个节点。

关键特点

  • 本地内存访问:CPU访问本地节点内存的延迟较低(约100纳秒)。
  • 远程内存访问:跨节点访问需通过互连链路,延迟显著增加(约200-300纳秒,甚至更高)。
延迟对比示例
内存访问类型典型延迟(纳秒)
本地内存(单CPU内)80-120 ns
跨节点内存(NUMA)200-400 ns
PCIe设备访问500-1000 ns

这里使用了最简单的办法:数据局部性优化

线程/进程绑定:将线程绑定到特定NUMA节点(如numactl),确保其使用本地内存。

由于程序使用 Alpine Linux 的 Docker 镜像运行,所以修改了 Dockerfile ,并且使用脚本来运行程序

FROM alpine:latest

RUN apk add --no-cache tzdata numactl numactl-tools
ENV TZ=Asia/Shanghai

WORKDIR /app
...
CMD [ "/app/entrypoint.sh" ]

entrypoint.sh

#!/bin/sh

# 读取环境变量 NUMA_NODES,默认为 0
NUMA_NODES=${NUMA_NODES:-0}

# 检查是否安装了 numactl
if command -v numactl > /dev/null 2>&1; then
    # 检查是否存在 NUMA 节点
    if numactl --hardware | grep -q "available: 0"; then
        echo "NUMA nodes not found, running command directly."
        /app/myapp /app/config.toml
    else
        echo "NUMA nodes detected, binding to NUMA node 0."
        numactl -N "$NUMA_NODES" /app/myapp /app/config.toml
    fi
else
    echo "numactl not found, running command directly."
    /app/myapp /app/config.toml
fi

经过NUMA绑定固定的节点,程序的OPS提升到了30W/s,已完全满足业务需求。

3、程序吞吐量还是没法超越Windows

经过上述的优化,虽然Linux的速率提升到了30W/s,但是Windows下也随着优化提升了速度,达到50W/s,接近单机Redis写入上限。

还记得上面说到,我的 Windows 开发机器CPU主频更高吗?标定 3.10GHz ,平常高性能模式睿频能达到 4.3GHz 。而这台 Linux 服务器的CPU主频只有可怜的 2.2GHz ,平常实际工作频率在 1.0GHz2.3GHz 。这也就是实际运算期间的速率差异了,这个只能通过更换硬件解决,单纯靠软件无法提升。

总结

通过对重复创建的对象进行池化、减少不必要代码的运行、基础值类运算优化、减少 Select 使用和操作系统层面优化,最终优化了大量性能问题,提升幅度巨大。上述优化方案仅供参考,请读者根据实际情况进行优化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值