本文深入探讨了如何通过细致的接口分析和数据库访问优化,显著提升后端Go服务的响应速度。我们将详细介绍使用
nginx
日志分析确定性能瓶颈、利用wrk
和pprof
工具进行性能测试和火焰图分析,以及实施有效的缓存策略和序列化优化。通过这些实战策略,我们成功降低了关键接口saveData
和rankInfo
的响应时间,显著提高了整体服务性能。这篇文章旨在为后端开发者提供一套全面的性能优化框架,帮助他们在面对类似挑战时能够迅速而有效地作出响应。
一、接口性能优化:深入分析与策略制定
在后端服务的稳定运行阶段,我们重点关注性能优化,以进一步提升系统效率和用户体验。利用 nginx
作为反向代理,我们实施了实时监控,密切关注接口调用的动态。通过执行 tail -f /var/log/nginx/access.log
命令,我们能够实时查看和分析接口日志。数据分析揭示了 saveData
和 rankInfo
这两个接口的调用频率高且响应时间较长,成为优化的重点目标。我们的主要任务是针对这两个接口实施优化措施,旨在降低它们的响应时间,从而全面提升服务性能和用户交互体验。
二、优化工具介绍
(一) wrk工具
- macOS 可以使用
brew install wrk
下载安装wrk
,编写rank_info.lua
脚本命令如下:
lua
wrk.method = "POST"
wrk.headers["Content-Type"] = "application/x-www-form-urlencoded"
wrk.body = "token=%242a%2410%24PoyTfVudtRoigDmbu48SPbmQSWbI&version=1.5&game_id=1000122&signature=399e292f805e2f06b6ce26f40966bb67bbd0bef0&rank_type=11&board_type=1101"
在本地开启后端服务后,可以执行以下命令进行 rankInfo 的接口性能测试
bash
wrk -t10 -d30s -c20 -s ./script/wrk/rank_info.lua <http://127.0.0.1:8080/v1.0/activity/rankInfo>
-- t 为线程数
-- d 为持续时间
-- c 为连接数
-- s 为脚本文件
测试结果如下
bash
Running 30s test @ <http://127.0.0.1:8080/v1.0/activity/rankInfo>
10 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 23.48ms 66.78ms 657.19ms 96.70%
Req/Sec 165.20 25.31 212.00 83.04%
47797 requests in 30.07s, 2.06GB read
Requests/sec: 1589.54
Transfer/sec: 70.04MB
可以用餐厅的运营来比喻 wrk 的输出结果,以便更通俗易懂地解释每个参数:
-
测试概况:
Running 30s test @ <http://127.0.0.1:8080/v1.0/activity/rankInfo
:这就像是在特定的餐厅(网址)进行为期> 30 秒的服务效率测试。10 threads and 20 connections
:想象有 10 个服务员(线程)在服务 20 桌客人(连接)。
-
线程统计(Thread Stats) :
Avg (平均延迟)
:平均每桌客人等待服务的时间。Stdev (标准偏差)
:客人等待时间的波动程度,波动越大,服务质量越不稳定。Max (最大延迟)
:最长的一次服务等待时间,即最不满意的客人等待的时间。+/- Stdev
:大多数服务时间是否接近平均值。
-
请求/秒(Req/Sec) :
Avg (平均)
:平均每秒服务的桌数,反映餐厅的服务效率。Stdev (标准偏差)
:服务效率的波动,波动越大,表明服务质量不稳定。Max (最大)
:在最佳情况下,每秒能服务的最多桌数。+/- Stdev
:大部分时间里,服务的桌数是否接近平均值。
-
总结:
47797 requests in 30.07s
:在 30.07 秒内,总共完成了 47797 次服务,类似于服务员总共服务的桌数。2.06GB read
:总共处理了 2.06GB 的数据,好比服务员总共处理的订单量。Requests/sec (每秒请求)
:平均每秒完成的服务次数,这里是 1589.54,反映了餐厅的服务效率。Transfer/sec (每秒传输)
:平均每秒处理的数据量,这里是 70.04MB,类似于每秒传递的菜品总量。
(二)pprof 工具
可以通过 go-pprof-practice 及其README文档,通过实践学习掌握go tool pprof
。在代码中引入 pprof,运行 wrk 后使用go tool pprof
命令,可以分析查看缓存和CPU等指标的性能火焰图。
bash
go tool pprof -http=:8081 "http://localhost:8080/debug/pprof/heap?seconds=20"
go tool pprof -http=:8082 "http://localhost:8080/debug/pprof/profile?seconds=20"
go tool pprof -http=:8083 /Users/light/pprof/pprof.samples.cpu.004.pb.gz
三、优化 rankInfo 接口
(一)火焰图分析
使用 pprof 分析 rankInfo 接口的火山图,发现官方的 encoding/json 包序列化耗时严重。
通过替换为高性能JSON包 go-json
和 jsoniter
并做对比,最近决定引入 jsoniter
替换。
go
import jsoniter "github.com/json-iterator/go"
var json = jsoniter.ConfigCompatibleWithStandardLibrary
再次使用火焰图查看反序列化耗时,同比减少 86.33%,优化效果显著。
(二)jsoniter 性能优化探究
1. 零内存分配
jsoniter
在处理 JSON 数据时,尽量减少内存分配。例如,它使用内部缓冲区来重用内存,避免了频繁的内存分配和释放。在源码中/go/pkg/mod/github.com/json-iterator/go@v1.1.12/stream.go
实现:
go
type Stream struct {
buf []byte // 内部缓冲区
// ...
}
func (stream *Stream) WriteString(s string) {
// 直接写入内部缓冲区,避免额外的内存分配
stream.buf = append(stream.buf, s...)
}
这种方法减少了垃圾回收的压力,提高了性能。
2. 延迟解析
jsoniter
实现了延迟解析机制,即在初始阶段不会解析所有数据,而是在需要时才进行解析。这在源码/go/pkg/mod/github.com/json-iterator/go@v1.1.12/any_number.go
中体现为:
go
func (any *numberLazyAny) ToFloat64() float64 {
iter := any.cfg.BorrowIterator(any.buf)
defer any.cfg.ReturnIterator(iter)
val := iter.ReadFloat64()
if iter.Error != nil && iter.Error != io.EOF {
any.err = iter.Error
}
return val
}
这个 ToFloat64
方法展示了延迟解析的一个关键特征:数据(在这个例子中是 any.buf
中的内容)直到 ToFloat64
方法被调用时才进行解析。这种方式可以提高效率,特别是在处理大型数据或在只有部分数据需要被解析的情况下。
-
创建迭代器并解析数据:
goiter := any.cfg.BorrowIterator(any.buf)
这行代码从配置
any.cfg
中借用一个迭代器,并使用numberLazyAny
实例中的缓冲区any.buf
作为数据源。这表明解析操作是在这一步开始的。 -
延迟返还迭代器:
godefer any.cfg.ReturnIterator(iter)
使用
defer
关键字确保在ToFloat64
方法结束时返还迭代器。这是 Go 语言中常用的资源管理模式。 -
读取浮点数:
goval := iter.ReadFloat64()
这行代码实际执行了解析操作,将缓冲区中的数据解析为
float64
类型的值。 -
错误处理:
goif iter.Error != nil && iter.Error != io.EOF { any.err = iter.Error }
如果在解析过程中遇到错误(除了
io.EOF
,表示数据结束),则将错误记录在numberLazyAny
实例的err
字段中。 -
返回解析结果:
goreturn val
方法返回解析得到的浮点数值。
3. 循环展开
循环展开是一种常见的优化技术,它通过减少循环的迭代次数来提高代码的执行速度。在处理 JSON 数据时,jsoniter 可能会展开处理每个字符的循环,从而减少循环的开销。例如在源码/go/pkg/mod/github.com/json-iterator/go@v1.1.12/iter_int.go
中看到类似以下的循环展开:
go
value := uint32(ind)
if iter.tail-iter.head > 10 {
i := iter.head
ind2 := intDigits[iter.buf[i]]
if ind2 == invalidCharForNumber {
iter.head = i
iter.assertInteger()
return value
}
i++
ind3 := intDigits[iter.buf[i]]
if ind3 == invalidCharForNumber {
iter.head = i
iter.assertInteger()
return value*10 + uint32(ind2)
}
...
}
-
初始条件检查:
goif iter.tail-iter.head > 10 {
这里检查缓冲区(
iter.buf
)中剩余未处理的字符数量是否足够多(大于 10),以进行循环展开。 -
逐个字符处理:
goi := iter.head ind2 := intDigits[iter.buf[i]]
从缓冲区的当前位置(
iter.head
)开始,取出一个字符并转换为数字(假设intDigits
是一个将字符映射到数字的数组)。这个过程重复多次(ind3
,ind4
, ...,ind9
),每次都检查字符是否有效并累加到value
中。 -
累加到
value
:goif ind2 == invalidCharForNumber { iter.head = i iter.assertInteger() return value }
如果当前字符不是有效数字(比如遇到了非数字字符),则更新
iter.head
(表示已处理到缓冲区的哪个位置),执行断言(可能是为了验证当前读取的内容是整数),并返回当前累积的value
。 -
循环展开的累加:
govalue*100 + uint32(ind2)*10 + uint32(ind3)
这里的代码通过乘以 10 的幂次来累加每个数字字符的值,这是手动展开循环的典型做法。例如,
ind2
被乘以 10,ind3
被直接加上,等等。
这些优化技术的应用使得 jsoniter 在处理 JSON 数据时具有很高的效率,特别是在处理大型或复杂的 JSON 结构时。
(三)数据库访问优化
为了减少数据库访问带来的耗时,我们从业务和代码两个层面进行了优化:
-
业务层面的优化:
- 与前端团队协作,优化返回的参数,以减少对数据库的访问需求。
- 调整缓存策略,将排行榜数据在缓存中的过期时间从原来的3秒延长至15秒,有效减轻数据库查询压力。
-
代码层面的优化:
- 将排行榜功能拆分为两个接口:一个用于查询所有用户的完整排行榜信息,另一个仅用于查询单个用户的排名信息。
- 利用 Redis 实现基于分数和更新时间的二维排序。首先使用
ZRevRange
获取前500名用户的排名和分数,然后通过HMGet
批量获取这些用户的更新时间,最后返回排序后的用户排名信息。相比之前逐个循环访问N次 Redis,降低为访问2次 Redis。 - 对于全量用户排行榜的展示,首先通过 Redis 的 pipeline 获取已缓存的用户扩展信息。对于未命中缓存的用户ID,进行有限次的数据库查询(考虑到分库分表的情况),获取所需信息后存入缓存,再进行二维排序。
四、saveData 接口优化
- 缓存策略调整 :在用户登录时,通过
getData
接口预加载数据库数据到 Redis 缓存中,使得后续频繁调用的saveData
接口能够直接命中缓存。 - 序列化性能提升 :替换标准的
encoding/json
包为更高效的jsoniter
包,显著降低了数据序列化和反序列化的耗时。 - 接口日志分析:通过分析接口日志,发现并优化了直接访问数据库的查询请求。改为首次查询数据库,后续则优先查询缓存,以此减少数据库访问,提高接口响应速度。
五、优化结果
经过上述优化措施部署并测试后,我们通过 tail -f /var/log/nginx/access.log
再次监控接口的响应时间。结果显示,rankInfo
接口的平均响应时间降低了52.58%,saveData
接口中位数响应时间降低了 11.76%。此外,得益于jsoniter
包的全量接口改造,还有4个接口的响应时间优化了10%以上,另外4个接口的响应时间优化了5%以上。