当我们搭建了一个完整的 Go 项目后,这个项目的性能如何呢?能否应对高并发流量(比如秒杀活动)呢?如果不能,我们该如何优化系统性能呢?性能优化的前提是确定服务瓶颈。
1. 分库分表
数据库通常是 Web 服务的性能瓶颈,为什么呢?因为一般情况下,单个数据库实例的QPS (Queries-Per-Second,每秒查询次数)也就几千,这只能满足小型系统的需求,而某些中大型系统有可能需要承接数万甚至数十万的 QPS,单个数据库实例是绝对扛不住的。那么如何优化数据库性能呢?一方面,可以通过提升机器性能来优化数据库配置,同时通过优化表结构、查询语句等方式尽可能地提升单个数据库实例的 QPS; 另一方面,也可以通过分库分表方式提升数据库的 QPS。
1.1 分库分表基本原理
分库的含义就是将一个数据库拆分成多个数据库,并部署到不同的机器。例如,我们可以将商城项目的用户表、商品表、订单表等分别拆分到3个数据库实例。这样一来,原本一个数据库实例的压力就分散到了 3 个数据库实例(用户库、商品库、订单库)。总体而言,数据库的性能得到了提升。
通过这种方式优化之后就能万无一失了吗?举一个例子,假设商品模块的访问 QPS 在 1 万以上,而单个数据库实例的 QPS 在几千。也就是说,拆分数据库之后,商品库的性能依然无法满足条件。还有什么办法可以优化数据库性能呢?这时候我们通常会选择主从架构(读写分离)方案。首先,从数据库实例会实时同步主数据库实例的数据;其次,读请求可以由从数据库实例处理,写请求由数据库实例处理。通过这种方式,数据库性能可以得到成倍的提升。
使用读写分离方案时,一定要注意:主数据库实例的数据同步到从数据库实例是有延迟的。也就是说,当执行了一条写请求(INSERT、UPDATE、DELETE)时,如果立即执行读请求(QUERY)查询对应的数据,结果可能不符合预期。因为这时候主数据库的数据修改可能还没有同步到从数据库。另外,使用读写分离方案时,项目中获取数据库实例的代码逻辑需要根据操作类型进行相应的调整,改造成本还是不小的。
那还有什么其他方案吗?我们可以在业务代码和数据库实例之间加一层代理服务。代理服务将自己伪装成数据库实例,接收业务请求并转发给后端真正的数据库实例。这样就只需要在代理服务上实现读写分离逻辑即可。
需要说明的是,我们不可能通过增加从数据库来无限制地提升数据库性能,毕竟主、从数据库实例之间的数据同步也是有开销的。另外,虽然可以部署多个从数据库实例,但是主数据库实例只有一个,也就是说通过这种方式无法提升写请求的 QPS。
还有什么方案能优化数据库性能吗?分表。分表就是将一张数据表拆分为多张数据表,这些数据表可以在一个数据库实例中,也可以在多个数据库实例中。众所周知,当一张数据表的数据量过大时,即使命中了索引查询也会变慢(如果没有命中索引,查询将会非常慢),这是因为 MySQL 数据库(以 InnoDB引擎为例)的索引基于 B+ 树实现,查询耗时将会随之增加。所以,分表可以在一定程度上提升数据库性能,即使这些拆分后的数据表都在同一个数据库实例中。当然,如果我们将这些拆分后的数据表分布在多个数据库实例中,数据库性能还可以得到大幅度提升。
分表有两种实现方式:
1)垂直分表:这种方式适用于列非常多的数据表,这时候我们可以将一些不常用的、数据量较大的列拆分到其他数据表。
2)水平分表:这种方式适用于数据量非常大(行记录非常多)的数据表。这是以订单表为例,介绍如何实现水平分表。水平分表的核心在于,以什么维度拆分数据表,比如我们可以按照时间拆分,可以按照订单 ID 拆分,也可以按照用户 ID 拆分。
那到底应该按照哪种方式水平分表呢?这取决于查询需求。设想一下,如果将订单表按照订单 ID 拆分为 64 张表,那么当用户查询自己的订单列表时,该如何实现呢?这时候你可能会说,将订单表按照用户 ID 拆分不就可以了,这样是能满足用户查询订单列表的需求,但是如果我们还需要根据订单 ID 查询订单数据呢?
当然,由于订单查询的维度通常比较多,但是水平拆分订单表的维度只能有一个,为了解决这一问题,我们可以通过二级索引建立其他维度与拆分维度之间的映射关系。比如,当以用户 ID 拆分订单表时,如果我们能够通过二级索引查询订单 ID 与用户 ID 的映射关系,那就能实现根据订单 ID 查询订单数据的需求。
最后,没有绝对完美的方案,分库分表确实能在一定程度上优化数据库性能,但是存在一些缺点,比如增加了系统复杂度。另外,分库分表之后的一些复杂查询(比如部分分页查询)的实现成本将会非常高,数据库事务可能也无法使用。
1.2 基于 Gorm 的分表
分库分表能在一定程度上优化数据库性能,但是为了实现分库分表,我们可能需要改造业务代码。当然,我们也可以尝试在框架层实现,这样就不需要大量改造业务代码了。
查看 Gorm 官方文档,可以看到 Gorm 框架本身就支持分表(基于一个插件实现),只是需要注意的是,通过这种方式实现的分表,需求拆分后的数据表都在同一数据库实例。
基于 Gorm 框架实现分表非常简单,只需要在初始化 Gorm 实例的时候引入分表插件,并声明需要拆分的数据表、拆分维度、拆分数据表目等就可以了,代码如下所示:
tx := db.GetDbInstance("")
_= tx.Use(sharding.Register(sharding.Config{
ShardingKey: "user_id",
NumberOfShards: 4,
PrimaryKeyGenerator: sharding.PKSnowflake,
},"mall_order"))
在上面的代码中,方法 tx.Use 用于注册插件,可以看到,我们将在订单表 mall_order 上使用分表插件,分表维度使用的是用户 ID (数据列 user_id),订单表将被拆分为 4 张子表。引入分表插件之后,当我们再次操作订单表时,分表插件将会改写 SQL 语句,转化为操作拆分后的订单子表,以创建订单的代码为例:
orderDao := db.NewOrderDbDao().WithDBInstance(tx)
_,err := orderDao.CreateOrder(context.Background(),entity.OrderInfo{
OrderId: uuids,
UserId: 125400,
TotalAmount: 100,
GoodsNum: 1,
})
2. 使用 Redis 缓存
数据库通常是 Web 服务的性能瓶颈,所以我们提出了分库分表。那如果分库分表之后,数据库仍然无法满足业务的性能要求,该怎么办?这时候我们可以加一层缓存来解决。Redis 是目前非常常用的缓存数据库之一,官方显示 Redis 单实例就可以提供 10 万的 QPS。
2.1 go-reids 的基本操作
go-redis 是一款常用的 Redis 客户端框架,支持多种类型的 Redis 客户端,包括单节点客户端、集群客户端、哨兵客户端等。 go-redis 实现了 Redis 客户端的所有命令,包括字符串命令、散列表命令、发布-订阅等,因此基于 go-redis 操作 Redis 客户端非常方便。想要使用 go-redis,第一步当然是初始化 Redis 客户端对象了,代码如下所示:
// 全局 Redis 客户端对象
var rdb *redis.Client
func initRedis(){
// 初始化 Redis 客户端对象
rdb = redis.NewClient(&redis.Options {
Addr: "127.0.0.1:6379",
DialTimeout: time.Millisecond * 100,
ReadTimeout: time.Millisecond * 100,
WriteTimeout: time.Millisecond * 100,
})
}
2.2 基于 Redis 的性能优化
众所周知,用户从浏览商品到下订单通常涉及几个接口:1. 搜索商品,比如按照类别或名称搜索商品;2. 查看商品详情; 3. 创建订单;4. 支付。下面以商品详情与创建订单接口为例,介绍如何基于 Redis 缓存优化 Go 服务性能。
商品详情的接口非常简单,就是根据商品 ID 查询商品数据表和商品 SKU 数据表,代码逻辑如下所示:
3. 使用本地缓存
上面讲解了基于 Redis 的 Go 服务性能优化方案,可以看到,在使用 Redis 缓存之后,服务性能提升了 10 倍以上。当然,Redis 的 QPS 也是有上限的,官方显示 Redis 单实例的 QPS 可以达到 10 万,那如果某些热点数据的访问 QPS 超过了 10 万呢?这时候 Redis 也难以满足性能要求,针对这种情况,我们还可以使用本地缓存,即将热点数据直接缓存在 Go 服务内存。
3.1 自己实现一个 LRU 缓存
LRU(Least Recently Used,最近最少使用) 是一种常见的缓存淘汰算法,为什么需要缓存淘汰呢?因为 Go 服务内存是有限的,所以我们能缓存的热点数据是有限的。假设我们缓存新的热点数据时内存使用量已经超过使用阈值,该怎么办?直接丢弃该数据吗?当然不是,通常我们会从缓存中删除不再使用的数据,再将当前数据写入缓存。如何确定哪些数据不会再被使用呢?未来的事情谁也无法确定,所以我们只能尽量淘汰那些大概率不会再使用的数据。这就是 LRU 算法的由来,该算法认为最近最少使用的数据,未来也大概率不会再被使用。
如何实现 LRU 缓存淘汰算法呢?思考一下,既然是缓存,首先,肯定需要一个数据结构来存储缓存数据(键-值对),这里可以使用散列表;其次,为了维护缓存数据的使用情况,我们可以使用链表,每次访问缓存数据时,将其移动到链表首部,这样链表尾部的缓存数据就是最近最少使用的。完整的数据结构定义如下所示:
type Lru struct {
max int
dataList *list.List
dataMap map[interface{}]*list.Element
rwlock sync.RWMutex
}
3.2 基于 bigcache 的性能优化
3.1 介绍了如何实现一个本地 LRU 缓存,可以看到它能满足基本的键-值对新增、查询等操作。 当然在实际项目开发过程中,我们并不会自已去实现一个本地缓存,通常会选择一些成熟的开源框架,这些框架的功能更完善,性能也更高。
4. 资源复用
数据库通常是 Web 服务的性能瓶颈,所以前面讲解了如何通过分库分表、缓存方案优化服务性能,接下来将重点介绍 Go 服务本身的性能优化方案,以资源复用为例,合理地复用资源甚至能数倍地提升 Go 服务本身的性能。下面介绍常见的资源复用方案,包括协程复用、连接复用以及对象复用。
4.1 协程复用之 fasthttp
Go 语言天然具备并发特性,基于 go 关键字就能很方便地创建一个可以并发执行的协程,并且协程占用的资源非常少,协程切换的开销也非常小,所以日常项目开发过程中我们会大量使用协程。以 Go 语言原生的 HTTP 框架为例,它针对每一个客户端连接都会创建一个新的协程,该协程用于处理当前客户端连接接收到的所有 HTTP 请求(直到连接关闭,当然如果客户端使用的是短连接,只会处理一个 HTTP 请求),如下面代码所示:
func (srv *Server) Serve(1 net.Listener) error {
//循环等待客户端连接
for {
rw,err := 1.Accept()
// 创建新的协程处理客户端请求
c := srv.newConn(rw)
go c.serve(connCtx)
}
}
在上面的代码中,Go 语言 HTTP 服务的核心流程就是:循环等待客户端建立连接,并为每一个客户端连接创建新的处理协程。思考一下,如果 Go 服务突然接收到高并发请求,那么协程数也会瞬间增加;如果客户端使用的是短连接,那么每一个协程只会处理一个 HTTP 请求,处理完就退出。也就是说,我们的 Go 服务一直在频繁地创建协程、频繁地销毁协程。
4.2 连接复用之连接池
当我们使用 Go 语言原生的 HTTP 客户端 http.Client 发起 HTTP 请求时,默认使用的就是长连接,并且底层维护了一个连接池。连接池的含义是,当一个 TCP 连接不再使用时,可以将其放回连接池(注意不是关闭连接,此时认为该 TCP 连接处于空闲状态)。当需要获取新的 TCP 连接时,只需要从连接池中获取即可。当然,连接池并不止这么简单。例如,连接池中最多能存储多少个空闲连接?如果某个 TCP 连接长期处于空闲状态,我们是任由该 TCP 连接一直存在还是主动关闭它呢?当需要获取空闲的 TCP 连接时,如果没有空闲连接怎么办?要新建连接吗?如果遇到突发流量,能无限制地新建连接吗?这些问题都可以在结构体 http.Transport 的定义中找到答案,参考下面的代码:
type Transport struct {
//空闲连接池(key 为协议类型、目标地址等组合)
idleConn map[connectMethodKey][]*persistConn
//等待空闲连接的队列
idleConnWait map[connectMethodKey]wantConnQueue
//连接数(key为协议类型、目标地址等组合)
connsPerHost map[connectMethodKey]int
//等待建立连接的队列
connsPerHostWait map[connectMethodKey]wantConnQueue
//禁用 HTTP 长连接
DisableKeepAlives bool
//最大空闲连接数,0 表示无限制
MaxIdleConns int
//每个 Host 的最大空闲连接数,默认为2
MaxIdleConnsPerHost int
//空闲连接超时时间,默认为 90s
IdleConnTimeout time.Duration
......
}
在上面的代码中,可以看到,空闲连接池 idleConn 是一个散列表,其中键的类型是协议类型(HTTP/HTTPS)、目标地址等基本信息的组合(可以理解为 Host),值表示空闲连接队列。字段 MaxIdleConns 定义了全局最大空闲连接数,字段 MaxIdleConnsPerHost 定义了每个 Host 的最大空闲连接数。思考一下,如果这两个字段配置不合理(过少)会怎么样?当遇到突发流量时,由于空闲连接数较少,只能临时新建 TCP 连接,回收时又由于空闲连接数限制,无法将这些连接放到连接池,只能关闭这些连接,那这和短连接又有什么区别呢?
4.3 对象复用之对象池
一般来说,创建协程都需要初始化协程对象 g、申请协程栈内存、初始化协程栈等,那如果能够复用协程对象 g(包括协程栈内存),是不是创建协程的流程就简化不少? Go 语言在每个逻辑处理器 P 以能让全局分别维护了一个空闲协程池(已经执行结束的协程),定义如下所示:
//逻辑处理器 P 上的空闲协程池
type p struct {
gFree struct {
gList
n int32
}
}
//全局空闲协程池
type schedt struct {
gFree struct {
lock mutex
stack gList
noStack gList
n int32
}
}
5. 其他
除了资源复用之外,还有一些方案可以提升 Go 服务本身的性能,比如并发编程、异步化处理、无锁编程等。
5.1 异步化处理
以创建订单接口为例,介绍如何通过异步化提升接口性能。
5.2 无锁编程
什么是无锁编程呢?在遇到并发问题时,我们通常通过锁(互斥锁)解决:操作数据之前加锁,操作完数据之后再释放锁。但是这样的话,每次操作数据都需要额外的两个步骤--加锁与释放锁,这可能会影响程序性能。所以我们又提出了乐观锁,乐观锁本质上是通过比较--交换(CAS)操作解决并发问题的。无锁编程其实并不是真正的无锁,本质上也是通过乐观锁实现并发编程的。
那么,通过乐观锁实现的并发计数器与基于互斥锁实现的并发计数器,哪种性能更好呢?我们可以使用 Go 语言自带的单元测试(性能测试)验证一下,代码如下所示:
//基于互斥锁实现的并发计数器
func BenchmarkMutex(b *testing.B){
var count int32
var lock sync.Mutex
b.RunParallel(func(pb *testing.PB){
for pb.Next(){
lock.Lock()
count++
lock.Unlock()
}
})
}
//基于CAS 实现的并发计数器
func BenchmarkCas(b *testing.B){
var count int32
b.RunParallel(func(pb *testing.PB){
for pb.Next(){
for !atomic.CompareAndSwapInt32(&count,count,count+1){
}
}
})
}
go test main_test.go -benchtime 1000000x -cpu 1,4,8,16,32,48 -count 1 -bench .
BenchmarkCas 1000000 15.98 ns/op
BenchmarkCas-4 1000000 100.3 ns/op
BenchmarkCas-8 1000000 87.91 ns/op
BenchmarkCas-16 1000000 101.8 ns/op
BenchmarkCas-32 1000000 121.1 ns/op
BenchmarkCas-48 1000000 226.1 ns/op
BenchmarkMutex 1000000 24.15 ns/op
BenchmarkMutex-4 1000000 104.4 ns/op
BenchmarkMutex-8 1000000 194.3 ns/op
BenchmarkMutex-16 1000000 225.0 ns/op
BenchmarkMutex-32 1000000 244.3 ns/op
BenchmarkMutex-48 1000000 262.1 ns/op
参考上面的输出结果,BenchmarkCas-n 表示当前 CPU核数为 n 的情况下,基于 CAS 实现的并发计数器平均耗时;BenchmarkMutex-n 表示当前 CPU 核数为 n 的情况下,基于互斥锁实现的并发计数器平均耗时。可以看到,基于 CAS 实现的并发计数器平均耗时低于基于互斥锁实现的并发计数器平均耗时。也就是说,在某些场景下,乐观锁(无锁编程)确实能提升程序性能。
最后补充一下,Go 语言对互斥锁其实也做了很多优化,而且互斥锁与乐观锁的性能孰优孰劣,不能一概而论,需要具体场景具体分析。