目录
背景
公司内部项目,使用了go作为开发语言。具体功能就是将一个Redis的数据通过 PSync
协议获取RDB、AOF文件,进行解析,转换为一条条Redis命令对象 Entry
。程序整体架构采用分模块设计,分为读取端(Reader)和写入端(Writer)两个模块,数据在程序内的 Reader
到 Writer
流动使用 Golang 的 Channel
进行。其中 Reader
往 Channel
中发送数据过程涉及 RDB 解析模块、发送超时处理、接受暂停 / 恢复指令等情况。由于 RDB 模块在另外的包中,所以为了兼顾超时处理和暂停 / 恢复指令,所以在Reader内部还使用了另一个Channel
来接续传递 Entry
。
问题
在 Windows
下开发时,性能问题没有凸显,由于整体吞吐量和性能还不错(OPS:35~40W),所以直接使用 Golang 的交叉编译功能,将程序直接编译成 Linux 版本上线运行。由于业务 Redis 生产和更新数据速度过快,导致数据大量堆积,且整体性能只有 Windows
下的 1/4
(OPS:10W)。
机器配置如下
除了我的CPU主频高(后面要考)之外,其他应该都是吊打我的机器才对啊ㄟ( ▔, ▔ )ㄏ
机器配置 | Windows | Ubuntu Linux |
---|---|---|
系统版本 | Windows 11 Pro 23H2 | LTS 22.04 |
CPU | Intel Core i5-10500 | Intel Xeon Gold 5218 * 2 (NUMA) |
内存 | 32 GiB | 128 GiB |
CPU主频 | 3.10GHz | 2.30GHz |
网络 | 1Gbps | 10Gbps |
剖析问题
一开始使用 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)提交超时和暂停/恢复功能
回顾一下背景
其中
Reader
往Channel
中发送数据过程涉及 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.0GHz
到 2.3GHz
。这也就是实际运算期间的速率差异了,这个只能通过更换硬件解决,单纯靠软件无法提升。
总结
通过对重复创建的对象进行池化、减少不必要代码的运行、基础值类运算优化、减少 Select 使用和操作系统层面优化,最终优化了大量性能问题,提升幅度巨大。上述优化方案仅供参考,请读者根据实际情况进行优化。