“ 架设一个亿级高并发系统,是多数程序员、架构师的工作目标。 许多的技术从业人员甚至有时会降薪去寻找这样的机会。但并不是所有人都有机会主导,甚至参与这样一个系统。这个系列我们通过虚构一个这样的系统,一步步来完善我们的架构理念。”
开篇
一个系统架构一般可以分为以下几层:
- 负载分配层
- 业务层
- 业务通信层
- 数据存储层
如下面就是一个经典架构图:
这次的主题是数据存储,数据层比服务层更需要设计。对于服务层来说,可以拿PHP写,明天你可以拿JAVA来写,但是如果你的数据结构开始设计不合理,将来数据结构的改变会花费你数倍的代价,老的数据格式向新的数据格式迁移会让你痛不欲生,既有工作量上的,又有数据迁移跨越的时间周期,有一些甚至需要半年以上。
01
—
数据存储的基本逻辑
早在互联网到来之前,程序的数据存储是大型机+Oracle的模式存在。一台天价的大型服务器、配置一个oracle数据软件,这两个东西异常强大但那价格是贵得让人受不了。
oracle是费用是按CPU算的,各位能想像么。
互联网公司初期还比较草根,没那么钱设计了用小型机+开源数据库的方式来解决这个问题。原理其实非常简单,就是将数据分片放在不同的空间中。(大道至简!!)
1. mysql不是海量数据操作困难么,我们就限定每张表只能存一部分数据,将所有的数据分散在不同的表中。
2. 单一小服务器不是计算资源有限么,我们就把数据放到不同的库中操作。
基于以上两个骚操作,就是我们常说的分库分表。
所以我们在进行数据库存储架构时需要考虑的问题主要有两个:
- 数据操作是否能够满足需求。
2. 当不满足需求时,数据扩容成本问题是否足够低。(至少不能停服扩容吧)
以上两个问题比较好理解,第1个就不说了,数据库得支持业务的需求数据操作需求。
关于第2条,一个平台的数据是逐步积累起来的。在平台只有几千万条数据时,你直接设计百亿条数据的存储架构,搞个100台数据库服务器,这样成本吃不消容易被老板弄死。我们在初期只需要用少量服务器,最后随着数据量的增加慢慢扩容资源。
02
—
扩容 - 分库分表
数据库的水平扩容是一个很大的技术难点,但是通过优化我们的分库分表策,还是可以在一定程度上减轻工作量。这个准则无论是放到代码编写,产品设计或是生活的方方面面都一样适用,一个好的设计往往可以为后期的升级维护带来便利。所以当我们遇到一个难以实现的设计时,也需要反思这种设计是否合理,是否会有更优的方案?
分库分表这个概念十分好理解,就是原来存储在一个数据表内的数据,通过某种规则平均的分散在多个数据库的多张同样结构的数据表中。
水平扩容:
我们假定以用户表(t_user)来举例子,假设当前这个表的数据量已经到达了2kw,相对500w这个临界值来说足足超过了4倍。那么我们如何通过分库分表来调整该表?
正如上面所说到的,我们可以对数据表其中一个或多个字段,通过某个可计算的平均的每次计算结果绝对一致的算法来分散存储我们的数据。可以很容易地想到很多种算法,比如对id进行mod运算,对创建时间按月分成多个表等等。
图1.分库分表示意图
如上图所展示的,表通过函数fx的计算后被分散到不同数据库的不同表中。这里可以简单代入一种分库算法——id取膜。
上图中一共分了三个数据库,每个库中又分了两张数据表。那么id % 3 == 0的数据都会集中到分库1中,分库1中的数据id % 2 == 0 的数据,又会存储在第一个分表中,其他如此类推
图2.水平扩容示意图
第一个显而易见的问题就是规则的变化和数据的迁移。
如果控制分库分表的规则是通过应用程序内完成的,规则的变化意味着必须重新发布使用新规则的应用集群。而数据迁移带来的麻烦则更加严重,在数据没有完成迁移之前,需要编写专门的脚本来处理数据的导出导入,不同的业务不同的表关系,都会使得这个脚本变得极其的复杂,而且还要同时兼顾增量数据同步,时间点,数据一致性等问题,稍有不慎,便会对用户的数据造成影响。
思考,是否有一种分库规则,在扩展分库的时候不需要进行规则的变化和数据的迁移呢?
分段式扩容:
答案当然是有的,就是将分库的规则修改为按段分库。如下图所示,如果我们分三个库,每个库中有两张分表(分表规则还是按取膜运算),那么一共可以存储3kw的数据,其中分库规则为id的值在[1,1kw]的会被存储到0库上,在[1kw+1, 2kw]范围的会被存储到1库,在[2kw+1, 3kw]范围的会被存储到2库。当我们的数据量突破3kw时,我们只需要增加一个分库,用于存储[3kw+1, 4kw]范围的数据即可,完全不需要对前三个分库的数据做处理。同理,也可以基于时间的分库方式。
图3.避免数据迁移和规则更新的分库示例图
这种分库的规则优势可以说是非常的明显了,但是这种分库规则会带来什么样的劣势呢?
可以想象,我们为什么要做分库分表?就是单库的性能已经不能满足我们日常的业务需求了,需要将单个数据库的性能压力分摊到多个数据库上。而上述这种分库方式,势必会导致insert/update压力都集中到一个数据库实例上,并不能很好得分摊性能压力。
混合式扩容:
那么现在第三个问题来了,是否还有什么分库规划方式,既能避免数据迁移的成本,又能解决单库性能热点问题的方案呢?
答案肯定也是有的,下面我们来介绍一下阿里云TDDL团队给出的几种水平扩展模式。
图4.水平扩展模式1
第一种水平扩展的模式如上图,在我们只有一个分库的时候,可以通过设定4个分表,示例中使用简单的id取膜分表方式。当单库的容量已经达到上限,我们可以通过增加一个数据库实例,把分表2,3整体迁移到新的实例上。这样做的好处是,只需把整表迁移到新的库中即可,无需考虑单条数据因为规则的变化而重新计算需要迁移到哪个库。当两个库也不够用时,以此类推,增加两个分库,分别把table1和table3迁移到新的分库即可。
上述方案有一个缺点,就是在从一个库到4个库的过程中,单表的数据量一直在增长。当单表的数据量超过一定范围时,可能会带来性能问题。另外当开始预留的分表个数用尽,到了4物理库每库1个表的阶段,再进行扩容的话,不可避免得要再次从表上下手。
为了解决模式1的问题,我们接下来看看模式是如何处理的:
图5.水平扩展模式2
模式2与模式1有类似之处,在扩展阶段,还是选择整表迁移的方式,为了简化说明,此处使用两个分表来做说明。
如上图5,扩展了分库,把table1整表迁移到新库中后,如果此时单边已经快接近500w,我们可以在每个分库中再创建一个分表,用于存放超过500w部分的数据。此时分库分表的规则就变为:
通过id % 2确定分库,然后通过id段[1+0.5kw, 1kw]的数据分表存放在table_0_1和table_1_1中。这样既满足的降低单表500w水平线值,也解决了热点数据库的问题。
如果随着时间的流逝,我们的数据库容量需要再次升级,也只需要重新购买两个实例,把table_0_1和table_1_1分别迁移到新的实例上即可,同理也可以通过为每个分库建立新分表来解决500w问题。
以上都是倍数增长的扩容方案,对于中小型的企业来说,数据库资源的开销很是很大的。用2实例到4实例的费用就增长了一倍,而从4实例到8实例又增长了一倍。那么非倍数扩容的方案是如何的呢?
其实原理是相通的,譬如我们从2实例扩容到3实例时,此时我们table_0和table_1很大可能已经饱和了(单表达到500w),我们可以新购一个实例,用于存放这两个“历史数据”表,另外两个则按照模式2进行扩展,这样单库热点问题还是平均到两个库上。当然,我们也可以通过给每个库增加一个分表,来达到每个分库都承担1/3的压力。只不过这种模式,对于分库分表的规则就提高了很大的复杂度。
图6.水平扩展模式3
03
—
高并发-多级缓存架构
数据库的存在是为了满足业务侧的数据查询需求,就是前面说的第一条数据库是否满足业务侧的操作需求。数据库通常在整个系统中都是性能短板,我们通常需要经过缓存业减少业务侧对数据库的操作请求。服务端缓存是整个缓存体系中的重头戏,关注到这篇文章时想必你了解到服务端缓存在系统性能的重要性。
数据库虽然整个系统中的“半吊子|慢性子”,但有时数据库调优却能够以小搏大,在不改变架构和代码逻辑的前提下,缓存参数的调整往往是条捷径。
在系统开发的过程中,可直接在平台侧使用缓存框架,当缓存框架无法满足系统对性能的要求时,就需要在应用层自主开发应用级缓存,也就是我们常用的缓存Redis这东西,那到底什么是平台级、应用级缓存呢?
平台级就是你所选择什么开发语言来实现缓存,而应用级缓存,则是通过应用程序来达到目的。
我们从最低层,一层层进行分析:
01 数据库缓存:
因为数据库属于IO密集型应用,主要负责数据的管理及存储。数据一多查询本身就有可能变慢, 这也是为啥数据上得了台面时,查询爱用索引提速的原因。当然数据库自身也有“缓存”来解决这个问题。
一个问题:数据多了查询不应该都慢吗?
这个其实不全对, 例如在“上亿行的数据表中”
- 如果只有一个连接,发送一个简单的sql请求,走索引查询会很快。
- 在高并发场景下,如果是并发请求的总量超过了磁盘的读写速度,就会变得很慢。
原因就是:简单的SQL返回的结果不会特别多,请求小,磁盘读写速度跟得上。总并发量过大超过吞吐上限,就出问题了。
数据库缓存是自身一类特殊的缓存机制。大多数数据库不需要配置就可以快速运行,但并没有为特定的需求进行优化。在数据库调优的时候,缓存优化你可以考虑下。
MySQL查询缓存
以MySQL为例,MySQL中使用了查询缓冲机制,将SELECT语句和查询结果存放在缓冲区中,以键值对的形式存储。以后对于同样的SELECT语句,将直接从缓冲区中读取结果,以节省查询时间,提高了SQL查询的效率。
Query cache作用于整个MySQL实例,主要用于缓存MySQL中的ResultSet,也就是一条SQL语句执行的结果集,所以它只针对select语句。
当打开 Query Cache 功能,MySQL在接收到一条select语句的请求后,如果该语句满足Query Cache的条件,MySQL会直接根据预先设定好的HASH算法将接收到的select语句以字符串方式进行 hash,然后到Query Cache中直接查找是否已经缓存。
如果结果集已经在缓存中,该select请求就会直接将数据返回,从而省略后面所有的步骤(如SQL语句的解析,优化器优化以及向存储引擎请求数据等),从而极大地提高了性能。
当然,若数据变化非常频繁的情况下,使用Query Cache可能会得不偿失。因为MySQL只要涉及到数据更改,就会重新维护缓存。
这样可以通过计算Query Cache的命中率来进行调整缓存大小。
检验Query Cache的合理性
检查Query Cache设置得是否合理,可以通过在MySQL控制台执行以下命令观察:
- SHOW VARIABLES LIKE '%query_cache%';
- SHOW STATUS LIKE 'Qcache%'; 通过检查以下几个参数可以知道query_cache_size设置得是否合理:
- Qcache_inserts:表示Cache多少次未命中然后插入到缓存
- Qcache_hits: 表示命中多少次,它可反映出缓存的使用效果。
如果Qcache_hits的值非常大,则表明查询缓冲使用非常频繁,如果该值较小反而会影响效率,那么可以考虑不用查询缓存;
- Qcache_lowmem_prunes: 表示多少条Query因为内存不足而被清除出Query_Cache。
如果Qcache_lowmem_prunes的值非常大,则表明经常出现缓冲不够的情况,因增加缓存容量。
- Qcache_free_blocks: 表示缓存区的碎片
Qcache_free_blocks值非常大,则表明缓存区中的碎片很多,可能需要寻找合适的机会进行整理。
通过 Qcache_hits 和 Qcache_inserts 两个参数可以算出Query Cache的命中率:
命中率 = Qcache_hits/(Qcache_hits+Qcache_inserts)
通过 Qcache_lowmem_prunes 和 Qcache_free_memory 相互结合,能更清楚地了解到系统中Query Cache的内存大小是否真的足够,是否频繁的出现因内存不足而有Query被换出的情况。
1.3.InnoDB的缓存性能
当选择 InnoDB 时,innodb_buffer_pool_size 参数可能是影响性能的最为关键的一个参数,它用来设置缓存InnoDB索引及数据块、自适应HASH、写缓冲等内存区域大小,更像是Oracle数据库的 db_cache_size。
简单来说,当操作InnoDB表的时候,返回的所有数据或者查询过程中用到的任何一个索引块,都会在这个内存区域中去查询一遍。
和MyISAM引擎中的 key_buffer_size 一样,innodb_buffer_pool_size设置了 InnoDB 引擎需求最大的一块内存区域,直接关系到InnoDB存储引擎的性能,所以如果有足够的内存,尽可将该参数设置到足够大,将尽可能多的InnoDB的索引及数据都放入到该缓存区域中,直至全部。
说到缓存肯定少不了,缓存命中率。那innodb该如何计算?
计算出缓存命中率后,再根据命中率来对
innodb_buffer_pool_size 参数大小进行优化
除开查询缓存。数据库查询的性能也与MySQL的连接数有关
table_cache 用于设置 table 高速缓存的数量。
show global status like 'open%_tables'; # 查看参数
由于每个客户端连接都会至少访问一个表,因此该参数与max_connections有关。当某一连接访问一个表时,MySQL会检查当前已缓存表的数量。
如果该表已经在缓存中打开,则会直接访问缓存中的表以加快查询速度;如果该表未被缓存,则会将当前的表添加进缓存在进行查询。
在执行缓存操作之前,table_cache参数用于限制缓存表的最大数目:
如果当前已经缓存的表未达到table_cache数目,则会将新表添加进来;若已经达到此值,MySQL将根据缓存表的最后查询时间、查询率等规则释放之前的缓存。
02 平台级缓存:
平台级缓存是指你所用什么开发语言,具体选择的是那个平台,毕竟缓存本身就是提供给上层调用。主要针对带有缓存特性的应用框架,或者可用于缓存功能的专用库。
如:
- PHP中的Smarty模板库
- Java中,缓存框架更多,如Caffeine, Ehcache,Cacheonix,Voldemort,JBoss Cache,OSCache等等。
Caffeine是一种高性能的缓存库,是基于Java 8的最佳(最优)缓存框架。
贴一下他和其他缓存的一些比较图:
1.如何使用:
Caffeine使用比较简单,API和Guava Cache一致:
public static void main(String[] args) {
Cache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
.expireAfterAccess(1,TimeUnit.SECONDS)
.maximumSize(10)
.build();
cache.put("hello","hello");
}
2、Caffeine 配置说明
注意:
- weakValues 和 softValues 不可以同时使用。
- maximumSize 和 maximumWeight 不可以同时使用。
- expireAfterWrite 和 expireAfterAccess 同时存在时,以 expireAfterWrite 为准。
3、软引用与弱引用
软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
弱引用:弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存
// 软引用
Caffeine.newBuilder().softValues().build();
// 弱引用
Caffeine.newBuilder().weakKeys().weakValues().build();
4.SpringBoot 集成 Caffeine 两种方式
SpringBoot 有两种使用 Caffeine 作为缓存的方式:
- 方式一:直接引入 Caffeine 依赖,然后使用 Caffeine 方法实现缓存。
- 方式二:引入 Caffeine 和 Spring Cache 依赖,使用 SpringCache 注解方法实现缓存。
5.SpringBoot 集成 Caffeine 方式一
Maven 引入相关依赖
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
毕竟是架构类的文章,这里就不再展开写了。Caffeine的具体使用方法和原理大家有兴趣可以去搜索引擎上找一下,相关内容很多。
03 应用级缓存:
当平台级缓存不能满足系统的性能时,就要考虑使用应用级缓存。 应用级缓存,需要开发者通过代码来实现缓存机制。
有些许 一方有难,八方支援 的感觉。自己搞不定 ,请教别人
这是NoSQL的战场,不论是Redis还是MongoDB,以及Memcached都可作为应用级缓存的技术支持。
一种典型的方式是每分钟或一段时间后统一生成某类页面存储在缓存中,或者可以在热数据变化时更新缓存。
为啥平台缓存还不能满足系统性能要求呢?它不是还可以减少应用缓存的网络开销吗
那你得看这几点:
面向Redis的缓存应用
Redis是一款开源的、基于BSD许可的高级键值对缓存和存储系统,例如:新浪微博有着几乎世界上最大的Redis集群。
微博是一个社交平台,其中用户关注与被关注、微博热搜榜、点击量、高可用、缓存穿透等业务场景和技术问题。Redis都有对应的hash、ZSet、bitmap、cluster等技术方案来解决。
在这种数据关系复杂、易变化的场景上面用到它会显得很简单。比如:用户关注与取消:用hash就可以很方便的维护用户列表,你可以直接找到key,然后更改value里面的关注用户即可。
如果你用 memcache ,那只能先序列化好用户关注列表存储,更改在反序列化。然后再缓存起来,像大V有几百万、上千万的用户,一旦关注/取消。
当前任务的操作就会有延迟。
Reddis主要功能特点
- 主从同步
Redis支持主从同步,数据可以从主服务器向任意数量的从服务器同步,从服务器可作为关联其他从服务器的主服务器。这使得Redis可执行单层树状复制。
- 发布/订阅
由于实现了发布/订阅机制,使得从服务器在任何地方同步树的时候,可订阅一个频道并接收主服务器完整的消息发布记录。同步对读取操作的可扩展性和数据冗余很有帮助。
04.多级缓存实例
一个使用了Redis集群和其他多种缓存技术的应用系统架构如图所示
负载均衡
首先,用户的请求被负载均衡服务分发到Nginx上,此处常用的负载均衡算法是轮询或者一致性哈希,轮询可以使服务器的请求更加均衡,而一致性哈希可以提升Nginx应用的缓存命中率。
什么是一致性hash算法?
hash算法计算出的结果值本身就是唯一的,这样就可以让每个用户的请求都落到同一台服务器。
默认情况下,用户在那台在服务器登录,就生成会话session文件到该服务器,但如果下次请求重新分发给其他服务器就又需要重新登录。
本地缓存未命中时如何解决?
如果Nginx应用服务器的本地缓存没有命中,就会进一步读取相应的分布式缓存——Redis分布式缓存的集群,可以考虑使用主从架构来提升性能和吞吐量,如果分布式缓存命中则直接返回相应数据,并回写到Nginx应用服务器的本地缓存中。
如果Redis分布式缓存也没有命中,则会回源到Tomcat集群,在回源到Tomcat集群时也可以使用轮询和一致性哈希作为负载均衡算法。
3.3.缓存算法
缓存一般都会采用内存来做存储介质,使用索引成本相对来说还是比较高的。所以在使用缓存时,需要了解缓存技术中的几个术语。
缓存淘汰算法
替代策略的具体实现就是缓存淘汰算法。
使用频率:
04
—
结束语
写了这么多,是不是感觉就是为不争气的数据擦屁股。(反正我是这样感觉的)其实数据库的设计初衷是为了解决数据结构化存储问题,当时设计数据库的时候并没有互联网的大并发应用场景,大家想想10, 20年前大家研发的都是什么程序。
经过这么多层的保护基本可以把90%以上的用户请求在缓存层已经处理掉了,剩下的10%左右毕竟得通过数据库自己来解决,这个时候通过数据库的分库分表把请求再平分到不同的机器上在单位时间、单一节点上要处理的请求已经不那么多了。再加上良好的索引配置,不争气的数据库完全能够处理。
IT行业通常有句话是没有最好的架构,只有最合适的架构。大家可以凭借以上内容再配合自己的业务情况设计出最合理的数据存储解决方案。
好了,今天的内容就到这里,下一章我们来讲一下“用卖火车票案例来说明一下服务架构和DDD领域建模”,进入我们的系统架构章节。
本文暂时没有评论,来添加一个吧(●'◡'●)