👉目录
1 缓存
2 并行化处理
3 批量化处理
4 数据压缩处理
5 无锁化
6 分片化
7 避免请求
8 池化
9 异步处理
10 总结
本文总结了在服务架构设计时,提升服务性能的 9 大常用办法,相信可以有效帮到你的日常工作。期待你的点赞转发收藏一键三连!
01
缓存雪崩:缓存雪崩是指缓存中的大量数据同时失效或者过期,大量的请求直接读取到下游数据库,导致数据库瞬时压力过大,通常的解决方案是将缓存数据设置的过期时间随机化。在事件服务中就是利用固定过期时间+随机值的方式进行文章的淘汰,避免缓存雪崩。 缓存穿透:缓存穿透是指读取下游不存在的数据,导致缓存命中不了,每次都请求下游数据库。这种情况通常会出现在线上异常流量攻击或者下游数据被删除的状况,针对缓存穿透可以使用布隆过滤器对不存在的数据进行过滤,或者在读取下游数据不存在的情况,可以在缓存中设置空值,防止不断的穿透。事件服务可能会出现查询文章被删除的情况,就是利用设置空值的方法防止被删除数据的请求不断穿透到下游。 缓存击穿:缓存击穿是指某个热点数据在缓存中被删除或者过期,导致大量的热点请求同时请求数据库。解决方案可以对于热点数据设置较长的过期时间或者利用分布式锁避免多个相同请求同时访问下游服务。在新闻业务中,对于热点新闻经常会出现这种情况,事件服务利用 golang 的 singlefilght 保证同一篇文章请求在同一时刻只有一个会请求到下游,防止缓存击穿。 热点 key:热点 key 是指缓存中被频繁访问的 key,导致缓存该 key 的分片或者 Redis 访问量过高。可以将可热点 key 分散存储到多个 key 上,例如将热点 key+序列号的方式存储,不同 key 存储的值都是相同的,在访问时随机访问一个 key,分散原来单 key 分片的压力;此外还可以将 key 缓存到机器内存中,避免 Redis 单节点压力过大,在新闻业务中,对于热点文章就是采用这种方式,将热点文章存储到机器内存中,避免存储热点文章 Redis 单分片请求量过大。
key val => key1 val 、 key2 val、 key3 val 、 key4 val
type LRUCache struct {
sync.Mutex
size int
capacity int
cache map[int]*DLinkNode
head, tail *DLinkNode
}
type DLinkNode struct {
key,value int
pre, next *DLinkNode
}
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} obj ;
可以看出不同中间件对于传统的 LRU 淘汰策略都进行了一定优化来保证服务性能,我们也可以参考不同的优化策略在自己的服务中进行缓存 key 的淘汰。
当数据库中的数据变更时,如何保证缓存跟数据库中的数据一致,通常有以下几种方案:
更新缓存再更新 DB,
更新 DB 再更新缓存,
先更新 DB 再删除缓存,
删除缓存再更新 DB。
这几种方案都有可能会出现缓存跟数据库中的数据不一致的情况,最常用的还是更新 DB 再删除缓存,因为这种方案导致数据不一致的概率最小,但是也依然会存在数据不一致的问题。例如在 T1 时缓存中无数据,数据库中数据为100,线程B 查询缓存没有查询到数据,读取到数据库的数据100然后去更新缓存,但是此时线程A 将数据库中的数据更新为99,然后在 T4 时刻删除缓存中的数据,但是此时缓存中还没有数据,在 T5 的时候线程B 才更新缓存数据为100,这时候就会导致缓存跟数据库中的数据不一致。
为保证缓存与数据库数据的一致性。常用的解决方案有两种,一种是延时双删,先删除缓存,后续更新数据库,休眠一会再删除缓存。文章池服务中就是利用这种方案保证数据一致性,如何实现延迟删除,是通过 go 语言中 channel 实现简单延时队列,没有引入第三方的消息队列,主要为了防止服务的复杂化;另外一种可以订阅 DB 的变更 binlog,数据更新时只更新 DB,通过消费 DB 的 binlog 日志,解析变更操作进行缓存变更,更新失败时不进行消息的提交,通过消息队列的重试机制实现最终一致性。
02
Redis 在版本6.0之前都是号称单线程模型,主要是利用 epllo 管理用户海量连接,使用一个线程通过事件循环来处理用户的请求,优点是避免了线程切换和锁的竞争,以及实现简单,但是缺点也比较明显,不能有效的利用 CPU 的多核资源。随着数据量和并发量的越来越大,I/O 成了 Redis 的性能瓶颈点,因此在6.0版本引入了多线程模型。Redis 的多线程将处理过程最耗时的 sockect 的读取跟解析写入由多个 I/O 并发完成,对于命令的执行过程仍然由单线程完成。
MySQL 的主从同步过程从数据库通过 I/Othread 读取住主库的 binlog,将日志写入到 relay log 中,然后由 sqlthread 执行 relaylog 进行数据的同步。其中 sqlthread 就是由多个线程并发执行加快数据的同步,防止主从同步延迟。sqlthread 多线程化也经历了多个版本迭代,按表维度分发到同一个线程进行数据同步,再到按行维度分发到同一个线程。
小到线程的并发处理,大到 Redis 的集群,以及 Kafka 的分 topic 分区都是通过多个client并行处理提高服务的读写性能。在我们的服务设计中可以通过创建多个容器对外服务提高服务的吞吐量,服务内部可以将多个串行的 I/O 操作改为并行处理,缩短接口的响应时长,提升用户体验。对于 I/O 存在相互依赖的情况,可以进行多阶段分批并行化处理,另外一种常见的方案就是利用 DAG 加速执行,但是需要注意的是 DAG 会存在开发维护成本较高的情况,需要根据自己的业务场景选择合适的方案。并行化也不是只有好处没有坏处的,并行化有可能会导致读扩散严重,以及线程切换频繁存在一定的性能影响。
03
Kafka 的消息发送并不是直接写入到 broker 中的,发送过程是将发送到同一个 topic 同一个分区的消息通过 main 函数的 partitioner 组件发送到同一个队列中,由 sender 线程不断拉取队列中消息批量发送到 broker 中。利用批量发送消息处理,节省大量的网络开销,提高发送效率。
Redis 的持久化方式有 RDB 跟 AOF 两种,其中 AOF 在执行命令写入内存后,会写入到 AOF 缓冲区,可以选择合适的时机将 AOF 缓冲区中的数据写入到磁盘中,刷新到磁盘的时间通过参数 appendfsync 控制,有三个值 always、everysec、no。其中 always 会在每次命令执行完都会刷新到磁盘来保证数据的可靠性;everysec 是每秒批量写入到磁盘,no 是不进行同步操作,由操作系统决定刷新到写回磁盘,当 Redis 异常退出时存在丢数据的风险。AOF 命令刷新到磁盘的时机会影响 Redis 服务写入性能,通常配置为 everysec 批量写入到磁盘,来平衡写入性能和数据可靠性。
我们读取下游服务或者数据库的时候,可以一次多查询几条数据,节省网络 I/O;读取 Redis 的还可以利用 pipeline 或者 lua 脚本处理多条命令,提升读写性能;前端请求 js 文件或者小图片时,可以将多个 js 文件或者图片合并到一起返回,减少前端的连接数,提升传输性能。同样需要注意的是批量处理多条数据,有可能会降低吞吐量,以及本身下游就不支持过多的批量数据,此时可以将多条数据分批并发请求。对于事件底层页服务中不同组件下配置的不同文章 id,会统一批量请求下游内容服务获取文章详情,对于批量的条数也会做限制,防止单批数据量过大。
04
Redis 的 AOF 重写是利用 bgrewriteaof 命令进行 AOF 文件重写,因为 AOF 是追加写日志,对于同一个 key 可能存在多条修改修改命令,导致 AOF 文件过大,Redis 重启后加载 AOF 文件会变得缓慢,导致启动时间过长。可以利用重写命令将对于同一个 key 的修改只保存一条记录,减小 AOF 文件体积。
大数据领域的 Hbase、Cassandra 等 NoSQL 数据库写入性能都很高,它们的底层存储数据结构就是 LSM 树(log structured merge tree),这种数据结构的核心思想是追加写,积攒一定的数据后合并成更大的 segement,对于数据的删除也只是增加一条删除记录。同样对一个 key 的修改记录也有多条。这种存储结构的优点是写入性能高,但是缺点也比较明显,数据存在冗余和文件体积大。主要通过线程进行段合并将多个小文件合并成更大的文件来减少存储文件体积,提升查询效率。
对于 Kafka 进行传输数据时,在生产者端和消费者端可以开启数据压缩。生产者端压缩数据后,消费者端收到消息会自动解压,可以有效减小在磁盘的存储空间和网络传输时的带宽消耗,从而降低成本和提升传输效率。需要注意生产者端和消费者端指定相同的压缩算法。
在降本增效的浪潮中,降低 Redis 成本的一种方式,就是对存储到 Redis 中的数据进行压缩,降低存储成本,重构后的内容微服务通过持久化存储全量数据,采用 snappy 压缩,压缩后只是原来数据的40%-50%;还有一种方式是将服务之间的调用从 http 的 json 改为 trpc 的 pb 协议,因为 pb 协议编码后的数据更小,提升传输效率,在服务优化时,将原来请求 tab 的协议从 json 转成 pb,降低几毫秒的时延,此外内容微服务存储的数据采用 flutbuffer 编码,相比较于 protobuffer 有着更高的压缩比跟更快的编解码速度;对于 JS/CSS 多个文件下发也可以进行混淆和压缩传递;对于存储在 es 中的数据也可以手动调用 api 进行段合并,减小存储数据的体积,提高查询速度;在我们工作中还有一个比较常见的问题是接口返回的冗余数据特别多,一个接口服务下发的数据大而全,而不是对于当前场景做定制化下发,不满足接口最小化原则,白白浪费了很多带宽资源和降低传输效率。
05
Redis 通过单线程避免了锁的竞争,避免了线程之间频繁切换才有了很好的读写性能。
go 语言中提供了 atomic 包,主要用于不同线程之间的数据同步,不需要加锁,本质上就是封装了底层 CPU 提供的原子操作指令。此外 go 语言最开始的调度模型时 GM 模型,所有的内核级线程想要执行 goroutine 需要加锁从全局队列中获取,所以不同线程之间的竞争很激烈,调度效率很差。
后续引入了 P(Processor),每一个 M(thread)要执行 G(gorontine)的时候需要绑定一个 P,其中P中会有一个待执行 G 的本地队列,只由当前M可以进行读写(少数情况会存在偷其他协程的 G),读取 P 本地队列时不需要进行加锁,通过降低锁的竞争大幅度提升调度 G 的效率。
MySQL 利用 mvcc 实现多个事务进行读写并发时保证数据的一致性和隔离型,也是解决读写并发的一种无锁化设计方案之一。它主要通过对每一行数据的变更记录维护多个版本链来实现的,通过隐藏列 rollptr 和 undolog 来实现快照读。在事务对某一行数据进行操作时,会根据当前事务 id 以及事务隔离级别判断读取那个版本的数据,对于可重复读就是在事务开始的时候生成 readview,在后续整个事务期间都使用这个 readview。MySQL 中除了使用 mvcc 避免互斥锁外,bufferpool 还可以设置多个,通过多个 bufferpool 降低锁的粒度,提升读写性能,也是一种优化方案。
日常工作 在读多写少的场景下可以利用 atomic.value 存储数据,减少锁的竞争,提升系统性能,例如配置服务中数据就是利用 atomic.value 存储的;syncmap 为了提升读性能,优先使用 atomic 进行 read 操作,然后再进行加互斥锁操作进行 dirty 的操作,在读多写少的情况下也可以使用 syncmap。
秒杀系统的本质就是在高并发下准确的增减商品库存,不出现超卖少卖的问题。因此所有的用户在抢到商品时需要利用互斥锁进行库存数量的变更。互斥锁的存在必然会成为系统瓶颈,但是秒杀系统又是一个高并发的场景,所以如何进行互斥锁优化是提高秒杀系统性能的一个重要优化手段。
另外一种优化方式可以参考 golang 的 GMP 模型,将库存分成多份,分别加载到服务 server 的本地,这样多机之间在对库存变更的时候就避免了锁的竞争。如果本地 server 是单进程的,因此也可以形成一种无锁化架构;如果是多进程的,需要对本地库存加锁后在进行变更,但是将库存分散到 server 本地,降低了锁的粒度,提高整个服务性能。
06
MySQL 的 InnoDB 存储引擎在创建主键时通常会建议使用自增主键,而不是使用 uuid,最主要的原因是 InnoDB 底层采用 B+树用来存储数据,每个叶子结点是一个数据页,存储多条数据记录,页面内的数据通过链表有序存储,数据页间通过双向链表存储。由于 uuid 是无序的,有可能会插入到已经空间不足的数据页中间,导致数据页分裂成两个新的数据页以便插入新数据,影响整体写入性能。
此外 MySQL 中的写入过程并不是每次将修改的数据直接写入到磁盘中,而是修改内存中 buffer pool 内存储的数据页,将数据页的变更记录到 undolog 和 binlog 日志中,保证数据变更不丢失,每次记录 log 都是追加写到日志文件尾部,顺序写入到磁盘。对数据进行变更时通过顺序写 log,避免随机写磁盘数据页,提升写入性能,这种将随机写转变为顺序写的思想在很多中间件中都有所体现。
kakfa 中的每个分区是一个有序不可变的消息队列,新的消息会不断的添加的 partition 的尾部,每个 partition 由多个 segment 组成,一个 segment 对应一个物理日志文件,kafka 对 segment 日志文件的写入也是顺序写。顺序写入的好处是避免了磁盘的不断寻道和旋转次数,极大的提高了写入性能。
顺序写主要会应用在存在大量磁盘 I/O 操作的场景,日常工作中创建 MySQL 表时选择自增主键,或者在进行数据库数据同步时顺序读写数据,避免底层页存储引擎的数据页分裂,也会对写入性能有一定的提升。
06
Redis 对于命令的执行过程是单线程的,单机有着很好的读写性能,但是单机的机器容量跟连接数毕竟有限,因此单机 Redis 必然会存在读写上限跟存储上限。Redis 集群的出现就是为了解决单机 Redis 的读写性能瓶颈问题,Redis 集群是将数据自动分片到多个节点上,每个节点负责数据的一部分,每个节点都可以对外提供服务,突破单机 Redis 存储限制跟读写上限,提高整个服务的高并发能力。除了官方推出的集群模式,代理模式 codis 等也是将数据分片到不同节点,codis 将多个完全独立的 Redis 节点组成集群,通过 codis 转发请求到某一节点,来提高服务存储能力和读写性能。
同样的 Kafka 中每个 topic 也支持多个 partition,partition 分布到多个 broker 上,减轻单台机器的读写压力,通过增加 partition 数量可以增加消费者并行消费消息,提高 Kafka 的水平扩展能力和吞吐量。
新闻每日会生产大量的图文跟视频数据,底层是通过 tdsql 存储,可以分采分片化的存储思想,将图文跟视频或者其他介质存储到不同的数据库或者数据表中,同一种介质每日的生产量也会很大,这时候就可以对同一种介质拆分成多个数据表,进一步提高数据库的存储量跟吞吐量。另外一种角度去优化存储还可以将冷热数据分离,最新的数据采用性能好的机器存储,之前老数据访问量低,采用性能差的机器存储,节省成本。
在某次微服务重构中,需要进行数据同步,将总库中存储的全量数据通过 Kafka 同步到内容微服务新的存储中,预期同步 QPS 高达15k。由于 Kafka 的每个 partition 只能通过一个消费者消费,要达到预期 QPS,因此需要创建750+partition 才能够实现,但是 Kafka 的 partition 过多会导致 rebalance 很慢,影响服务性能,成本和可维护行都不高。采用分片化的思想,可以将同一个 partition 中的数据,通过一个消费者在内存中分片到多个 channel 上,不同的 channel 对应的独立协程进行消费,多协程并发处理消息提高消费速度,消费成功后写入到对应的成功 channel,由统一的 offsetMaker 线程消费成功消息进行 offset 提交,保证消息消费的可靠性。
07
为提升写入性能,MySQL 在写入数据的时候,对于在 bufferpool 中的数据页,直接修改 bufferpool 的数据页并写 redolog;对于不在内存中的数据页并不会立刻将磁盘中的数据页加载到 bufferpool 中,而是仅仅将变更记录在缓冲区,等后续读取磁盘上的数据页到 bufferpool 中时会进行数据合并,需要注意的是对于非唯一索引才会采用这种方式,对于唯一索引写入的时候需要每次都将磁盘上的数据读取到 bufferpool 才能判断该数据是否已存在,对于已存在的数据会返回插入失败。
另外 MySQL 查询例如 select * from table where name = 'xiaoming' 的查询,如果name字段存在二级索引,由于这个查询是*,表示需要所在行的所有字段,需要进行回表操作,如果仅需要 id 和 name 字段,可以将查询语句改为 select id , name from tabler where name = 'xiaoming' ,这样只需要在 name 这个二级索引上就可以查到所需数据,避免回表操作,减少一次 I/O,提升查询速度。
Web 应用中可以使用缓存、合并 css 和 js 文件等,避免或者减少 http 请求,提升页面加载速度跟用户体验。
在日常移动端开发应用中,对于多 tab 的数据,可以采用懒加载的方式,只有用户切换到新的tab之后才会发起请求,避免很多无用请求。服务端开发随着版本的迭代,有些功能字段端上已经不展示,但是服务端依然会返回数据字段,对于这些不需要的数据字段可以从数据源获取上就做下线处理,避免无用请求。另外在数据获取时可以对请求参数的合法性做准确的校验,例如请求投票信息时,运营配置的投票 ID 可能是“” 或者“0”这种不合法参数,如果对请求参数不进行校验,可能会存在很多无用 I/O 请求。另外在函数入口处通常会请求用户的所有实验参数,只有在实验期间才会用到实验参数,在实验下线后并没有下线 ab 实验平台的请求,可以在非实验期间下线这部分请求,提升接口响应速度。
08
golang 作为现代原生支持高并发的语言,池化技术在它的 GMP 模型就存在很大的应用。对于 goroutine 的销毁就不是用完直接销毁,而是放到 P 的本地空闲队列中,当下次需要创建 G 的时候会从空闲队列中直接取一个 G 复用即可;同样的对于M的创建跟销毁也是优先从全局队列中获取或者释放。此外 golang 中 sync.pool 可以用来保存被重复使用的对象,避免反复创建和销毁对象带来的消耗以及减轻 gc 压力。
MySQL 等数据库也都提供连接池,可以预先创建一定数量的连接用于处理数据库请求。当请求到来时,可以从连接池中选择空闲连接来处理请求,请求结束后将连接归还到连接池中,避免连接创建和销毁带来的开销,提升数据库性能。
在日常工作中可以创建线程池用来处理请求,在请求到来时同样的从链接池中选择空闲的线程来处理请求,处理结束后归还到线程池中,避免线程创建带来的消耗,在 Web 框架等需要高并发的场景下非常常见。
09
异步处理在数据库中同样应用广泛,例如 Redis 的 bgsave,bgrewriteof 就是分别用来异步保存 RDB 跟 AOF 文件的命令,bgsave 执行后会立刻返回成功,主线程 fork 出一个线程用来将内存中数据生成快照保存到磁盘,而主线程继续执行客户端命令;Redis 删除 key 的方式有 del 跟 unlink 两种,对于 del 命令是同步删除,直接释放内存,当遇到大 key 时,删除操作会让 Redis 出现卡顿的问题,而 unlink 是异步删除的方式,执行后对于 key 只做不可达的标识,对于内存的回收由异步线程回收,不阻塞主线程。
MySQL 的主从同步支持异步复制、同步复制跟半同步复制。异步复制是指主库执行完提交的事务后立刻将结果返回给客户端,并不关心从库是否已经同步了数据;同步复制是指主库执行完提交的事务,所有的从库都执行了该事务才将结果返回给客户端;半同步复制指主库执行完后,至少一个从库接收并执行了事务才返回给客户端。有多种主要是因为异步复制客户端写入性能高,但是存在丢数据的风险,在数据一致性要求不高的场景下可以采用,同步方式写入性能差,适合在数据一致性要求高的场景使用。此外对于 Kafka 的生产者跟消费者都可以采用异步的方式进行发送跟消费消息,但是采用异步的方式有可能会导致出现丢消息的问题。对于异步发送消息可以采用带有回调函数的方式,当发送失败后通过回调函数进行感知,后续进行消息补偿。
在做服务性能优化中,发现之前的一些监控上报,曝光上报等操作都在主流程中,可以将这部分功能做异步处理,降低接口的时延。此外用户发布新闻后,会将新闻写入到个人页索引,对图片进行加工处理,标题进行审核,或者给用户增加活动积分等操作,都可以采用异步处理,这里的异步处理是将发送消息这个动作发送消息到消息队列中,不同的场景消费消息队列中的消息进行各自逻辑的处理,这种设计保证了写入性能,也解耦不同场景业务逻辑,提高系统可维护性。
10
本文主要总结进行服务性能优化的几种方式,每一种方式在我们常用的中间件中都有所体现,我想这也是我们常说多学习这些中间件的意义,学习它们不仅仅是学会如何去使用它们,也是学习它们底层优秀的设计思想,理解为什么要这样设计,这种设计有什么好处,后续我们在架构选型或者做服务性能优化时都会有一定的帮助。此外性能优化方式也给出了具体的落地实践。
希望通过实际的应用例子加强对这种优化方式的理解。此外要做服务性能优化,还是要从自身服务架构出发,分析服务调用链耗时分布跟 CPU 消耗,优化有问题的 RPC 调用和函数。
FunTester 原创精华