参考
关于分布式数据库,你需要知道的一些事(上)
关于分布式数据库,你需要知道的一些事(中)
关于分布式数据库,你需要知道的一些事(下)
sharding:谁都能读懂的分库、分表、分区
关于分库分表最全的一篇文章
分库分表需要考虑的问题及方案
MySQL 对于千万级的大表要怎么优化?
随着互联网的飞速发展,人类社会的数据量迅速激增,据统计目前人类一年产生的数据就相当于人类进入现代化以前所有历史的总和,而且互联网业务的发展通常具有爆发性,业务量很可能在短短的一个月内突然爆发式地增长几千倍,对应的数据也很可能快速地从原来的几百GB飞速上涨到了几百个TB。如果在这爆发的关键时刻,系统不稳定或无法访问,那么对于业务将会是毁灭性的打击。这时,传统的单机数据库提供的服务,在系统可扩展性、性价比方面已不再适用。伴随着对于系统性能、成本以及扩展性的新需求,分布式数据库系统应运而生,力求突破单机MySQL容量和性能瓶颈,彻底消除单机数据库无法支撑企业业务高速发展的后顾之忧。
一、概述
以电商网站为例,在网站创建之初,日均访问量可能只有几百到几千人,这时整个业务后台可能就一个数据库,所有业务表都放在这个数据库中,一台普通的服务器就可以支撑,而且这种架构对业务开发人员也非常友好,因为所有的表都在一个库中,这样查询语句就可以灵活关联了,使用起来很便捷。
但是随着业务的不断发展,每天访问网站的人越来越多,数据库的压力也越来越大。通过分析发现,所有的访问流量中,80%以上都是读流量,只有20%左右的写流量,这时可以通过读写分离来缓解数据库的访问压力。
图2 读写分离
由于网站的访问量越来越大,尽管采取了读写分离的方式,但随着数据库的压力继续增加,数据库的瓶颈越来越突出。这时我们发现,我们的网站演进到现在,交易、商品、用户的数据都还在同一个数据库中。然而在这个巨大而且臃肿的数据库中,表和表之间的数据很多是没有关系的,也不需要JOIN操作,理论上就应该把它们分别放到不同的服务器,即垂直分库。
图3 垂直分库
随着业务的不断增长,我们发现交易、商品、用户这些库都变得巨大无比,单机数据库已经无法满足业务的继续增长,这时可以考虑对这些表进行水平拆分,即同一个表中的数据拆分到两个甚至多个数据库中。以用户表为例,数据可以根据userid的奇偶来确定数据的划分。把id为奇数的放到DB1,为偶数的放DB2。
图4 水平分表
二、垂直切分
垂直切分的优点如下:
- 拆分后业务清晰,拆分规则明确。
- 系统之间进行整合或扩展很容易。
- 按照成本、应用的等级、应用的类型等将表放到不同的机器上,便于管理。
- 便于实现动静分离、冷热分离的数据库表的设计模式。
- 数据维护简单。
垂直切分的缺点如下:
- 部分业务表无法关联(Join),只能通过接口方式解决,提高了系统的复杂度。
- 受每种业务的不同限制,存在单库性能瓶颈,不易进行数据扩展和提升性能。
- 事务处理复杂。
垂直切分除了用于分解单库单表的压力,也用于实现冷热分离,也就是根据数据的活跃度进行拆分,因为对拥有不同活跃度的数据的处理方式不同。
我们可将本来可以在同一个表中的内容人为地划分为多个表。所谓“本来”,是指按照关系型数据库第三范式的要求,应该在同一个表中,将其拆分开就叫作反范化(Denormalize)。
例如,对配置表的某些字段很少进行修改时,将其放到一个查询性能较高的数据库硬件上;对配置表的其他字段更新频繁时,则将其放到另一个更新性能较高的数据库硬件上。
这里我们再举一个例子:在微博系统的设计中,一个微博对象包括文章标题、作者、分类、创建时间等属性字段,这些字段的变化频率低,查询次数多,叫作冷数据。而博客的浏览量、回复数、点赞数等类似的统计信息,或者别的变化频率比较高的数据,叫作活跃数据或者热数据。
我们把冷热数据分开存放,就叫作冷热分离,在MySQL的数据库中,冷数据查询较多,更新较少,适合用MyISAM引擎,而热数据更新比较频繁,适合使用InnoDB存储引擎,这也是垂直拆分的一种。
我们推荐在设计数据库表结构时,就考虑垂直拆分,根据冷热分离、动静分离的原则,再根据使用的存储引擎的特点,对冷数据可以使用MyISAM,能更好地进行数据查询;对热数据可以使用InnoDB,有更快的更新速度,这样能够有效提升性能。
其次,对读多写少的冷数据可配置更多的从库来化解大量查询请求的压力;对于热数据,可以使用多个主库构建分库分表的结构,请参考关于水平切分的内容。
注意,对于一些特殊的活跃数据或者热点数据,也可以考虑使用Memcache、Redis之类的缓存,等累计到一定的量后再更新数据库,例如,在记录微博点赞数量的业务中,点赞数量被存储在缓存中,每增加1000个点赞,才写一次数据。
三、水平拆分
水平切分
与垂直切分对比,水平切分不是将表进行分类,而是将其按照某个字段的某种规则分散到多个库中,在每个表中包含一部分数据,所有表加起来就是全量的数据。
简单来说,我们可以将对数据的水平切分理解为按照数据行进行切分,就是将表中的某些行切分到一个数据库表中,而将其他行切分到其他数据库表中。
这种切分方式根据单表的数据量的规模来切分,保证单表的容量不会太大,从而保证了单表的查询等处理能力,例如将用户的信息表拆分成User1、User2等,表结构是完全一样的。我们通常根据某些特定的规则来划分表,比如根据用户的ID来取模划分。
例如,在博客系统中,当读取博客的量很大时,就应该采取水平切分来减少每个单表的压力,并提升性能。
以微博表为例,当同时有100万个用户在浏览时,如果是单表,则单表会进行100万次请求,假如是单库,数据库就会承受100万次的请求压力;假如将其分为100个表,并且分布在10个数据库中,每个表进行1万次请求,则每个数据库会承受10万次的请求压力,虽然这不可能绝对平均,但是可以说明问题,这样压力就减少了很多,并且是成倍减少的。
水平切分的优点如下:
- 单库单表的数据保持在一定的量级,有助于性能的提高。
- 切分的表的结构相同,应用层改造较少,只需要增加路由规则即可。
- 提高了系统的稳定性和负载能力。
水平切分的缺点如下:
- 切分后,数据是分散的,很难利用数据库的Join操作,跨库Join性能较差。
- 拆分规则难以抽象。
- 分片事务的一致性难以解决。
- 数据扩容的难度和维护量极大。
四、线上迁移方案
参考【面试宝典】如何把单库数据迁移到分库分表?
假设你的分库分表中间件已经选好了,分库分表的数据库都已经建好了。分库分表的功能也都已经测试通过了,可以上线了。数据迁移的系统也测试通过。
方案一:停机迁移
这个方案是一般公司都比较常用的,在网站上放一个公告,说“几点到几点要进行系统升级,到时候系统将不可用”,类似这样的公告。然后大家就一起加班,从凌晨开始做数据迁移,搞了多个数据迁移的工具,从单库把数据获取到,在调用分库分表中间件,中间件把数据放到新的数据库中,整个过程可能要跑个几小时,迁移完之后大家开始验证数据。验证成功之后修改应用系统的配置,把原来调用单库的配置修改为使用中间件。然后在验证下功能,没问题的话就可以对外提供服务了。
image.png缺点:
- 1、一定会出现几小时的停机(凌晨也还好,很多用户都睡觉了)
- 2、如果在凌晨 4 点还没有搞定,大家开始慌了,到凌晨 6 点还没搞定,那么新库的数据回滚,单库继续提供服务,第二天在继续搞。(这样大家会很累)
方案二:不停机迁移(双写方案)
如果不想经常看凌晨的太阳,那么会想有没有其他的方案?
image.png
写数据库的代码,同时写单库和新库; 2、数据迁移工具获取到老库的数据,在新库中进行比较,如果新库中不存在,那么把数据保存到新库中;如果新库中存在数据,那么比较更新时间,如果老库的更新时间大于新库,那么修改新库的数据;如果老库的更新时间小于新库的,那么保持不变。 3、迁移完之后需要比较数据是否完全一样,在凌晨的时候,数据肯定会变为一致,因为很少数据进来。 4、最后凌晨,系统把写老库的代码删掉,都写新库。 整个迁移的过程就结束了,这个过程只有在删除写老库代码的时候,会停下服务(你使用了集群,其实对用户来说是无感知的)。
注:如果想数据迁移快点,那么可以多部署一些数据迁移工具。
以下参考谈谈自己的大数据迁移经历
1.限流
迁移说白了就是把老的数据读出来,写入到新的数据源。但对于访问量极大的系统,绝对不能无节制的对数据进行读写。任何系统的带宽都是有限的,磁盘的IO也是有限的,所以一定要限流。我们的迁移脚本在读取和写入数据时都都会监控所消耗的时间。如果超过阈值就开始短暂的等待。比如读取100条数据花100ms以上,脚本机会停顿1s;如果超过200ms,脚本就会停顿5s。超过一定阈值,脚本会发邮件通知我:“迁移暂时中断,需要人工重启”等等。
这样的限流脚本,一旦开启后,大部分时间我就不用盯着,不用操心生产系统被压垮。只用每天花点时间看看进度就行了。
2.出错处理
这么浩大的开发过程,不出错时完全不可能的。所以必须提前设计出错时如何追踪错误。而我们的处理是一定要把一条评论的新老两个ID在新系统都要记录下来。一旦发现数据有问题,可以立刻反查原始数据。
3.迁移幂等
迁移脚本会出错,而迁移本身是并不是原子的——因为业务的复杂性,我们无法用transaction。所以,我们的迁移必须是幂等的。我们利用了原始数据的服务名称+ID作为幂等key(比如PHOTO:12345),以及INSERT IGNORE INTO,配合进度控制可以实现简单的重启数据迁移。这样即便迁移脚本被强行的kill掉,同时又没有记录下精确的动作。我也可以放心大胆的随意重启它。
五、节选知乎专栏 支撑百万并发的数据库架构如何设计?
注:本文关于分库分表的部分不再转述
1、配置
通常来说,假如你用普通配置的服务器来部署数据库,那也起码是 16 核 32G 的机器配置。这种非常普通的机器配置部署的数据库,一般线上的经验是:不要让其每秒请求支撑超过 2000,一般控制在 2000 左右。控制在这个程度,一般数据库负载相对合理,不会带来太大的压力,没有太大的宕机风险。
所以首先第一步,就是在上万并发请求的场景下,部署个 5 台服务器,每台服务器上都部署一个数据库实例。
2、全局唯一 id 如何生成
这里可以对比参考一下分库分表之后,id 主键如何处理?
在分库分表之后你必然要面对的一个问题,就是 id 咋生成?因为要是一个表分成多个表之后,每个表的 id 都是从 1 开始累加自增长,那肯定不对啊。举个例子,你的订单表拆分为了 1024 张订单表,每个表的 id 都从 1 开始累加,这个肯定有问题了!你的系统就没办法根据表主键来查询订单了,比如 id = 50 这个订单,在每个表里都有!
所以此时就需要分布式架构下的全局唯一 id 生成的方案了,在分库分表之后,对于插入数据库中的核心 id,不能直接简单使用表自增 id,要全局生成唯一 id,然后插入各个表中,保证每个表内的某个 id,全局唯一。比如说订单表虽然拆分为了 1024 张表,但是 id = 50 这个订单,只会存在于一个表里。
那么如何实现全局唯一 id 呢?有以下几种方案:
方案一:独立数据库自增 id
这个方案就是说你的系统每次要生成一个 id,都是往一个独立库的一个独立表里插入一条没什么业务含义的数据,然后获取一个数据库自增的一个 id。拿到这个 id 之后再往对应的分库分表里去写入。
比如说你有一个 auto_id 库,里面就一个表,叫做 auto_id 表,有一个 id 是自增长的。
那么你每次要获取一个全局唯一 id,直接往这个表里插入一条记录,获取一个全局唯一 id 即可,然后这个全局唯一 id 就可以插入订单的分库分表中。
这个方案的好处就是方便简单,谁都会用。缺点就是单库生成自增 id,要是高并发的话,就会有瓶颈的,因为 auto_id 库要是承载个每秒几万并发,肯定是不现实的了。
适合的场景:你分库分表就俩原因,要不就是单库并发太高,要不就是单库数据量太大;除非是你并发不高,但是数据量太大导致的分库分表扩容,你可以用这个方案,因为可能每秒最高并发最多就几百,那么就走单独的一个库和表生成自增主键即可。
方案二:UUID
参考
UUID是如何保证唯一性的?
UUID 简史
[翻译]Golang中生成良好的唯一ID的方式
UUID的版本 UUID具有多个版本,每个版本的算法不同,应用范围也不同。
首先是一个特例--Nil UUID--通常我们不会用到它,它是由全为0的数字组成,如下: 00000000-0000-0000-0000-000000000000
- UUID Version 1:基于时间的UUID 基于时间的UUID通过计算当前时间戳、随机数和机器MAC地址得到。由于在算法中使用了MAC地址,这个版本的UUID可以保证在全球范围的唯一性。但与此同时,使用MAC地址会带来安全性问题,这就是这个版本UUID受到批评的地方。如果应用只是在局域网中使用,也可以使用退化的算法,以IP地址来代替MAC地址--Java的UUID往往是这样实现的(当然也考虑了获取MAC的难度)。
- UUID Version 2:DCE安全的UUID DCE(Distributed Computing Environment)安全的UUID和基于时间的UUID算法相同,但会把时间戳的前4位置换为POSIX的UID或GID。这个版本的UUID在实际中较少用到。
- UUID Version 3:基于名字的UUID(MD5) 基于名字的UUID通过计算名字和名字空间的MD5散列值得到。这个版本的UUID保证了:相同名字空间中不同名字生成的UUID的唯一性;不同名字空间中的UUID的唯一性;相同名字空间中相同名字的UUID重复生成是相同的。
- UUID Version 4:随机UUID 根据随机数,或者伪随机数生成UUID。这种UUID产生重复的概率是可以计算出来的,但随机的东西就像是买彩票:你指望它发财是不可能的,但狗屎运通常会在不经意中到来。 UUID Version 5:基于名字的UUID(SHA1) 和版本3的UUID算法类似,只是散列值计算使用SHA1(Secure Hash Algorithm 1)算法。
UUID.randomUUID().toString().replace(“-”, “”) -> sfsdf23423rr234sfdaf
好处就是每个系统本地生成,不要基于数据库来了。不好之处就是,UUID 太长了,作为主键性能太差了,不适合用于主键。
如果你是要随机生成个什么文件名了,编号之类的,你可以用 UUID,但是作为主键是不能用 UUID 的。
方案三:获取系统当前时间
这个方案的意思就是获取当前时间作为全局唯一的 id。但是问题是,并发很高的时候,比如一秒并发几千,会有重复的情况,这个肯定是不合适的。
一般如果用这个方案,是将当前时间跟很多其他的业务字段拼接起来,作为一个 id,如果业务上你觉得可以接受,那么也是可以的。
你可以将别的业务字段值跟当前时间拼接起来,组成一个全局唯一的编号,比如说订单编号:时间戳 + 用户 id + 业务含义编码。
方案四、snowflake 算法
snowflake 算法是 twitter 开源的分布式 id 生成算法,就是把一个 64 位的 long 型的 id,1 个 bit 是不用的,用其中的 41 bit 作为毫秒数,用 10 bit 作为工作机器 id,12 bit 作为序列号。
- 1 bit:不用,为啥呢?因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0。
- 41 bit:表示的是时间戳,单位是毫秒。41 bit 可以表示的数字多达 2^41 - 1,也就是可以标识 2^41 - 1 个毫秒值,换算成年就是表示69年的时间。
- 10 bit:记录工作机器 id,代表的是这个服务最多可以部署在 2^10台机器上哪,也就是1024台机器。但是 10 bit 里 5 个 bit 代表机房 id,5 个 bit 代表机器 id。意思就是最多代表 2^5个机房(32个机房),每个机房里可以代表 2^5 个机器(32台机器)。
- 12 bit:这个是用来记录同一个毫秒内产生的不同 id,12 bit 可以代表的最大正整数是 2^12 - 1 = 4096,也就是说可以用这个 12 bit 代表的数字来区分同一个毫秒内的 4096 个不同的 id。
0 | 0001100 10100010 10111110 10001001 01011100 00 | 10001 | 1 1001 | 0000 00000000
①1 bit:是不用的,为啥呢?
因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0。
②41 bit:表示的是时间戳,单位是毫秒。
41 bit 可以表示的数字多达 2^41 - 1,也就是可以标识 2 ^ 41 - 1 个毫秒值,换算成年就是表示 69 年的时间。
③10 bit:记录工作机器 id,代表的是这个服务最多可以部署在 2^10 台机器上,也就是 1024 台机器。
但是 10 bit 里 5 个 bit 代表机房 id,5 个 bit 代表机器 id。意思就是最多代表 2 ^ 5 个机房(32 个机房),每个机房里可以代表 2 ^ 5 个机器(32 台机器)。
④12 bit:这个是用来记录同一个毫秒内产生的不同 id。
12 bit 可以代表的最大正整数是 2 ^ 12 - 1 = 4096,也就是说可以用这个 12 bit 代表的数字来区分同一个毫秒内的 4096 个不同的 id。简单来说,你的某个服务假设要生成一个全局唯一 id,那么就可以发送一个请求给部署了 SnowFlake 算法的系统,由这个 SnowFlake 算法系统来生成唯一 id。
这个 SnowFlake 算法系统首先肯定是知道自己所在的机房和机器的,比如机房 id = 17,机器 id = 12。接着 SnowFlake 算法系统接收到这个请求之后,首先就会用二进制位运算的方式生成一个 64 bit 的 long 型 id,64 个 bit 中的第一个 bit 是无意义的。接着 41 个 bit,就可以用当前时间戳(单位到毫秒),然后接着 5 个 bit 设置上这个机房 id,还有 5 个 bit 设置上机器 id。最后再判断一下,当前这台机房的这台机器上这一毫秒内,这是第几个请求,给这次生成 id 的请求累加一个序号,作为最后的 12 个 bit。
这个算法可以保证说,一个机房的一台机器上,在同一毫秒内,生成了一个唯一的 id。可能一个毫秒内会生成多个 id,但是有最后 12 个 bit 的序号来区分开来。
SnowFlake 算法一个小小的改进思路:其实在实际的开发中,这个SnowFlake算法可以做一点点改进。因为大家可以考虑一下,我们在生成唯一 id 的时候,一般都需要指定一个表名,比如说订单表的唯一 id。所以上面那 64 个 bit 中,代表机房的那 5 个 bit,可以使用业务表名称来替代,比如用 00001 代表的是订单表。因为其实很多时候,机房并没有那么多,所以那 5 个 bit 用做机房 id 可能意义不是太大。
这样就可以做到,SnowFlake 算法系统的每一台机器,对一个业务表,在某一毫秒内,可以生成一个唯一的 id,一毫秒内生成很多 id,用最后 12 个 bit 来区分序号对待。
3、读写分离
这个时候整体效果已经挺不错了,大量分表的策略保证可能未来 10 年,每个表的数据量都不会太大,这可以保证单表内的 SQL 执行效率和性能。然后多台数据库的拆分方式,可以保证每台数据库服务器承载一部分的读写请求,降低每台服务器的负载。
但是此时还有一个问题,假如说每台数据库服务器承载每秒 2000 的请求,然后其中 400 请求是写入,1600 请求是查询。也就是说,增删改的 SQL 才占到了 20% 的比例,80% 的请求是查询。此时假如说随着用户量越来越大,又变成每台服务器承载 4000 请求了。那么其中 800 请求是写入,3200 请求是查询,如果说你按照目前的情况来扩容,就需要增加一台数据库服务器。
但是此时可能就会涉及到表的迁移,因为需要迁移一部分表到新的数据库服务器上去,是不是很麻烦?
其实完全没必要,数据库一般都支持读写分离,也就是做主从架构。写入的时候写入主数据库服务器,查询的时候读取从数据库服务器,就可以让一个表的读写请求分开落地到不同的数据库上去执行。这样的话,假如写入主库的请求是每秒 400,查询从库的请求是每秒 1600。
写入主库的时候,会自动同步数据到从库上去,保证主库和从库数据一致。然后查询的时候都是走从库去查询的,这就通过数据库的主从架构实现了读写分离的效果了。
现在的好处就是,假如说现在主库写请求增加到 800,这个无所谓,不需要扩容。然后从库的读请求增加到了 3200,需要扩容了。这时,你直接给主库再挂载一个新的从库就可以了,两个从库,每个从库支撑 1600 的读请求,不需要因为读请求增长来扩容主库。
实际上线上生产你会发现,读请求的增长速度远远高于写请求,所以读写分离之后,大部分时候就是扩容从库支撑更高的读请求就可以了。而且另外一点,对同一个表,如果你既写入数据(涉及加锁),还从该表查询数据,可能会牵扯到锁冲突等问题,无论是写性能还是读性能,都会有影响。
所以一旦读写分离之后,对主库的表就仅仅是写入,没任何查询会影响他,对从库的表就仅仅是查询。
网友评论