写在最开始
我曾经在一家线上教育公司的初创型业务线干过3年,之后又去了另一个高速增长期的公司干了3年。参与过一些规模不同的系统的搭建,改造和升级。从只有几台线上服务器到几百台由k8s管理的集群。也见证过业务发展的不同阶段:从初创期的让业务先跑起来到几万几十万用户使用到几十亿几百亿的资金收入。在这过程中,我慢慢对架构升级有了一些沉淀和想法。可能并不完全甚至还有些浅薄,但写下来算是对从业8年的一个总结和思考。
后端的架构其实就是围绕高性能,高可用和可扩展来做文章。而性能瓶颈又常常体现在数据的读和写。数据的读写性能好,往往系统的性能也就不错。所以会花很多篇幅去聊我对于存储系统的理解。
架构升级从何处入手
我在第一家公司的时候年纪还很小,对技术充满了好奇心。什么新的都想试一试,网上说的高大上的东西都得弄到自己的业务代码中用一用。后面我开始独立负责一些服务的时候,这种现象已经发展到比较夸张的地步。几万人使用的系统,不到百万的数据量已经开始分库分表;完全可以不用分布式事务的场景开始引入seata;完全用不到降级和限流的系统引入了sentinal。那一年的晋升答辩中,我兴奋地夸夸其谈我对架构的理解,而老师们的回复是我完全不懂什么是架构。
我很快加入了另一家处于高速增长期的公司。最开始只是做些crud的基础工作,到第二年开始负责一个老旧系统的升级和改造。当时的公司左手是社交右手是电商,而这个老旧系统聚合了所有B端账号的业务域。也就是说,如果你想卖货你需要有一个B端账号;如果你是一个MCU,你需要培养艺人做内容,你需要有一个B端账号;如果你是一个行业的KOL,你想接广告,你需要有一个B端账号。B端账号维系着全公司的变现入口,所以没有人敢动,因此系统也就越来越老旧。这个任务最终落在我头上。虽然很痛苦经历了很多很多折磨,但我也逐渐开始理解和认同当年老师们的回复。
脱离了现实的架构没有任何意义,"一步到位"的架构更没有意义。滥用技术带来复杂度的提升,而复杂度带来新的问题。软件的本质应该是可用的,高性能的。如果能达到这些标准,再适当的考虑可以扩展的余地就已经很好了。所以我后来开始知道架构升级应该从何处入手,要从先了解当前系统开始。系统的问题出在哪里,有多大影响,是否可以容忍这些影响,如果不能需要怎么处理。
存储是最常见的瓶颈
以我的经验来看,业务逻辑再复杂都很难成为系统的性能瓶颈。经常让人挠破脑袋的是数据丢了,不一致了,数据处理的不够快。数据读写中的不合理是最容易出现的问题,且带来的影响也很大(会引起整个系统不可用),往往要花很多时间和精力去处理。所以我认为后端架构升级的最核心应该是数据存储的升级。
关于存储架构的升级。我自己总结了一套思路。
- 优化mysql
- 加入缓存
- 数据异构
- 读写分离
- 分库分表

我会围绕这些部分聊下我遇到的问题和思考。
1: 优化mysql
了解当前情况
在开始对数据库进行优化之前,我习惯先了解下当前运行的状况。我们知道影响mysql处理能力的因素不止一个,搭载mysql服务器的配置,mysql的一些参数配置,数据库中的总数据量大小,是否存在数据高峰,数据有多少命中innoDB的缓存池等都会影响。但总的来说,如果一台16g8核的数据库在读写比例正常(8:1)的qps能达到3000,CPU利用率不超过70%,我会认为是比较好的。如果CPU利用率不低但QPS不高,需要引起警惕。是不是不合理的查询有点多,比如说多表join,无索引扫描。反之如果CPU利用率和QPS都很高,也需要重视是不是已经要撑不住业务流量了。在高速增长期的公司会经常出现这种情况。基础件完善的公司通常都会提供DB的数据面板和与之匹配的监控告警,我们只需要保持关注就好。如果是创业期的公司,可能需要自己用一些命令:top可以显示mysql的cpu利用率,show processlist可以查看慢查询,两次使用show global status like 'Questions'可以得到qps,show global status like'Innodb_buffer_pool_read%'可以查看缓存读的状况。
优化:连接数,缓存读,redoLog
mysql撑不住一方面的原因是单机性能确实压榨到极限了,扛不住业务的增长。另一方面是单机性能利用就不高。在这部分我们主要关注单机性能的问题。
首先要先看mysql的配置。连接数和缓存读是我认为比较重要的两个方面。连续数要和CPU核数匹配。如果连接数过少,那么大量请求等待连接池释放出连接才能进行db操作,大量请求处于阻塞等待状态,此时应用机器就会受到大量压力且本身性能也没有利用好。
如果连接数过多,比如一台16g8核的服务器开到200(这是一个很常见的错误配置)。此时虽然请求不再阻塞,但是CPU同时只能处理8个线程,大量的线程就会处于就绪-等待的状态中。CPU来回切线程,每个线程被切走要保存寄存器中的信息,切回要恢复,带来很多额外开销。并且每个连接自己也会占用内存的,100-200个线程吃掉2-4个G的内存很容易。
合适的线程池数量应该是CPU的2-4倍,20-50是8核cpu的最佳配置。
还有些配置需要稍微了解一点点innodb的机制。第一个是调大缓存区innodb_buffer_pool_size,一般是内存的75%,这样能存储更点热点数据更容易击中缓存读。第二个是当redolog中的writePos追上checkPoint的时候会刷回磁盘,因此可以调大redolog文件innodb_log_file_size的大小来减少刷盘频率。
优化:慢sql
通常的慢sql我分成读和写。读是3种:聚合函数或者多表连接的复杂查询,没有命中索引,没有合适的索引(没有或区分度太差)。写是2种:大事务和锁竞争。
读中的第2和第3个问题都和索引有关系。索引的优化是一个老生常谈的话题。我通常的经验是第一个关注扫描行数,第二个关注回表次数。扫描行数少的方案通常情况下更有可能被优化器选中执行,回表次数少的方案通常可以减少磁盘IO效率来提高读的效率。读的第一个问题,无论是多表连接还是聚合函数对字段进行计算,通常都是业务需求问题。一般数据异构的思路都可以解决。比如数据分析的需求用clickhouse或者hbase。多表连接用es。
写中的第一个问题:大事务,一次批量更新100W条数据。这种推荐一般是离线任务去做。也可以大拆小,拆成1W次更新100条数据的小事务。第二种就是竞争锁,这种比较常见的是多个请求高频写同一行数据。比如直播间1W个人同时抢一个商品,大家都去扣那个商品的库存。这种也有几种方案,一种是把一行热点数据拆解成多行。比如库存是1W个,在数据库中表现为1行数据,我可以拆成100行数据每个库存是100;二个是扣缓存,数据库由消息队列异步去扣。我自己常年做B端业务,遇到这样的场景还真是不太多。
2: 数据异构
当我们在优化mysql的过程中,会发现有些问题是无法通过数据库来完成的。比如说上文提到的多表连接的问题。在我刚加入前司时候是做CRM的,经常做的功能就是向销售展示他们的状态。而状态的信息来自于各个业务域,比如说线索池中有多少线索,转化了多少来自于线索。他们的客户有没有充值和消费来自于订单。客户投了哪个笔记哪个Kol来自于商品。而这些状态又往往跟着搜索的需求,比如要搜索某个纸尿裤的客户是否投了某个母婴行业的KOL的笔记。这种用数据库实现基本上不太可能。一是因为必然多表连接,5-6个join家常便饭,中间还会有对字段的计算。二个是搜索用like的话,如果是abd%这种还好,如果是%abc就是灾难了。总结就是效率又低,又容易拖垮mysql。
上述场景的理想解决方案当然是es。es倒排索引的特性天生支持搜索。如果数据散落在一个数据库的不同表只需要听一个canal的binlog就可以了。如果数据库涉及到跨库跨源,可以消费消息队列。在应用层做一层mapping就可以入库es了。当然这里会涉及到多源数据一致性的问题,我的思路是通过一个离线的定时任务去自动对账。其实具体实践过程中canal可靠性是非常高的,因为可以支持从已经同步的binlog位置续传,数据是不会丢的,只是可能在短时有延迟。而数据看板是一个对延迟没有特别敏感的场景,所以就还好。

上述对es的应用只是数据异构的一种。我们后来还接触过广告投放资质的问题,因为每个行业对广告投放资质的要求不同,文件格式也是不一样的。对于mysql来说,就不知道如何去设计表中的列。而json文件就能很好的处理这类问题,不管你是什么样的格式,我一个大json包罗万物。传统的思路就是用MongoDB去储存json文件。当然mysql后来支持json文件以后,就可以顺势存进数据库减少一个数据源的管理。这也是数据异构思路的一种体现。
当然还有比如说用户的头像,图片音频这种也不适合存入mysql。因为它们占用的储存空间比较大。我们通常都会选择对象储存服务OSS,数据库里只存桶的key。本质上一种K-V对的储存思路。
3: 缓存
缓存是我认知中比较复杂的一环,它的复杂度来自于击穿和雪崩。我们曾经在这里吃过大亏。虽然它的思路是比较简单的,就是存一份数据到缓存中。多个请求都读缓存,缓存如果无法命中再去数据库查询并加载缓存。写请求是先更新数据库再更新缓存。经典的缓存策略Read Through/Write Through。
我们使用缓存的目的是为了减少数据库的压力来保护数据库。但在缓存雪崩的场景下,有时候反而会带崩所有数据库。我还在第一家公司的时候曾经遇到的一个问题:有几天晚上10点-11点数据库CPU经常会飙升到100%,所有SQL全部超时,页面打不开。但是过了11点又会慢慢恢复。

排查了一些慢日志,定时任务都没有起色。最后排查到redis,发现生成缓存的时间非常非常长。突然领悟到发生了缓存雪崩。因为主页上的数据越来越多,加载越来越慢,我们就加了一个缓存,过期时间是10分钟。当缓存过期的时候,大量请求进来发现无法命中缓存就去查了数据库,然后重新生成了缓存。偏偏这个重新生成缓存本身就是个慢操作,后台有很多表的join。大量的请求都在重新生成缓存直接拖累了整个数据库。
缓存雪崩的本质原因是大量请求击穿了缓存,也就是发生了大规模的缓存穿透。要解决这个问题,思路是有没有可能不发生缓存穿透?就是每一个读请求都能命中缓存呢。每一个读无非读到和读不到。读到了皆大欢喜,主要问题是读不到。而读不到也只有两种可能,确实没有这个数据或者有这个数据缓存中没有。对于情况一,访问确实不存在的数据,对系统来说就不是一个有用的请求,可以使用布隆过滤器处理掉。对于情况二,是缓存不过期。用canal接受数据库的增量,然后去刷新缓存。
但我们使用了方案二之后,发现会产生新的问题。老生常谈的延迟问题其实还好,binlog还没更新缓存的时候用户会读到过期数据的是可以容忍的。比较麻烦的是如果canal出了问题比如宕机了,这个更新缓存的操作就丢失了。那么缓存中就会存在一份没有更新的数据,如果恰好是一份冷门数据的话,就会保存很久。并且缓存不过期的话,缓存越来越多,也会形成内存压力。这样的话我们还是重新设置了缓存的过期时间,只是稍微比以前更长。并且有个后台的定时任务会去找redis中的idletime踢掉一些缓存。读操作还是全部走缓存,写操作只写数据库不写缓存。

4: 读写分离
在前面的部分,我们讨论了优化mysql来压榨数据库服务器单机的性能,引入多个数据库来把数据存放到合适的数据容器中,加入缓存来剔除重复访问。其实对于普通的小微业务,到这里应该也就可以支撑了。但是当业务真的在快速增长的时候,真的撑不了这么多QPS的时候,怎么办?
加入更多的mysql服务器来组成集群抗衡压力是一个思路。但是这样一来我们就需要路由来指定写在哪台mysql的服务器上。如果每一台数据库都保存了全量的数据,我们还需要考虑如何在分布式节点中保持数据一致性。如果我们在保持一致性的过程中,因为这个过程需要时间,恰好发生了很多读。他们是不是不会读到旧数据。此时我们是返回旧数据呢还是返回数据不可用呢?这里就会牵扯到分布式著名的CAP理论,返回旧数据保证了可用性但牺牲了一致性,返回不可用保证了一致性牺牲了可用性。听上去就很复杂对不对?
假如我们不需要所有节点都保存全量数据,那么就相当于把数据拆散保存在多个地方了,此时就形成了数据分片。如何分是一个问题,分的数据是否均匀?读的时候应该访问哪台机器?又会带来新的很多问题。
但是读写分离的出现就像久旱逢甘霖,解决了所有的问题。所有数据节点保存全量数据,写走主库就避免了分布式数据库路由写带来的问题。用binlog进行主从同步,保证了最终一致性。
实现上我们只需要利用mysql自带的主从同步机制,经过简单的配置就可以完成一个主库和多个从库之间的数据同步。更神奇的是对于后端来说,往往只需要引入sharding-jdbc这种厉害的插件就可以完成写操作走主库,读操作走从库的规则。
我们只需要先配置好静态解析的规则,shardingJDBC可以通过sql解析器对SQL语句进行语法分析,提取操作类型,表名和条件等关键信息。比如DML中的insert,update,delete,还有DDL中的create table。还有最重要的事务相关语句commit,rollback,begin。这些都会走主库。其他的select会走从库。有一个例外是select for update会走主库,此时会被认为是写操作。shardingJDBC也提供了动态路由规则的高级用法。并且自带负载均衡策略。
yaml
rules: readwrite-splitting:
# 逻辑数据源名称
data-sources: testdb:
# 静态规则
type: Static
# 写操作绑定主库
props: write-data-source-name: master
# 读操作绑定从库列表
read-data-source-names: slave1,slave2
读写分离的最大问题还是复制延迟。尤其是对于刚刚更新完的数据立马读,这种是会有概率读到旧数据的。但是解决办法也很容易想到,只要加入事务中,就会默认从主库了。更容易解决的办法是凡是有可能会产生过期读的地方都强制走主库就可以了。由于大部分互联网系统都是读多写少,读写分离的集群又很容易扩展。我认为这种方式是很有效的一种实现高性能存储的手段。
5:分库分表
分库分表是会巨量提升复杂度的一种方案。比如说分库了你的连接表查询是不是需要需要通过RPC了,RPC的延迟怎么办,RPC宕机了怎么办。如果说通过消息队列或者binlog,一样会产生延迟或者中间件宕掉的风险。还有分表了你的分页查询如何做,是不是要全部取出来在内存中再排序。有很多很多问题。我早年间不懂事的时候曾经尝试过引入分表,但是后来的职业生涯中再没有遇到过需要分库分表的场景。在实现高性能存储之路上,这可能是万不得已才使用的策略。
写在最结尾
在这篇文章中,我回顾了我经历的种种案例。在我刚从大学毕业的时候面试过一家金融公司,大哥和我说计算机所有的东西最后落脚都在数据上。开始时不屑一顾,后来渐行渐悟。诚然,后端架构还有很多可以说的。比如说负载均衡,异地多活,高扩展架构等等等。但计算机科学确实是一门构建在数据之上的艺术。大哥的金融公司是做P2P的,没多久就暴雷倒闭了,但他的话一直留在我脑子里。