单台 MySQL 支撑不了这么多的并发请求,我们该怎么办?
关系型数据库的事务特性可以帮我们解决很多难题,比如数据的一致性问题,所以常规业务持久化存储都会mysql 来兜底。但mysql 的性能是有限的。当业务规模发展到上百万用户,访问量达到上万QPS时,单台mysql实例很难应付。
有哪些解决方案?
1、首先我们会想到给数据库找一个搭档,也就是缓存
目前市面上经典组合是mysql+redis。Redis 作为 MySQL 的前置缓存,可以替 MySQL 挡住绝大部分查询请求,很大程度上缓解了 MySQL 并发请求的压力。
Redis 是一个使用内存保存数据的高性能 KV 数据库,它的高性能主要来自于:
- 简单的数据结构;
- 使用内存存储数据
内存是一种易失性存储,所以使用内存保存数据的 Redis 不能保证数据可靠存储。从设计上来说,Redis 牺牲了大部分功能,牺牲了数据可靠性,换取了高性能。但也正是这些特性,使得 Redis 特别适合用来做 MySQL 的前置缓存。
Redis提供的api比较简单,开箱即用,社区成熟,网上的资料也比较全,常见的问题网上基本都能找到解决方案,如果一个公司要使用缓存,90%以上都会选择redis。
虽然redis入门很容易,但用好redis也不是一件容易的事。要考虑缓存穿透、缓存雪崩、缓存热点、缓存命中率低、以及缓存数据不一致等问题。
2、读写分离
缓存缺失是可以解决大部分的性能问题,业界也有句话,“性能不够,缓存来凑”。像商品详情页、秒杀等场景,典型的读多写少,非常适合使用缓存,缓存的命中率可以达到90% 以上,将缓存的价值发挥到了极致。
但有些用户维度业务场景,比如:用户订单列表、账户系统、购物车系统。这些业务场景都是与用户挂钩,作用范围较窄,缓存效果大打折扣。
总结一下,互联网业务大部分都是读多写少,区别在于影响范围。有些是全局性的,如“修改一件商品信息,所有用户看到的是一份数据”;有些是局部性,如“用户小张刚下了一笔订单,查订单列表时,要带出最新的这条订单信息”。
全局性的读多写少,我们可以引入缓存。但是局部性的读多写少呢?这部分流量通常还是打在了mysql上,但是单台 MySQL 支撑不了这么多的并发请求时,我们该怎么办?
一个简单而且非常有效的方案是,使用多个具有相同数据的 MySQL 实例来分担大量的查询请求,这种方法通常称为“读写分离”。
一个分布式的存储系统,想要做分布式写是非常非常困难的,因为很难解决好数据一致性的问题。但实现分布式读就相对简单很多,我只需要增加一些只读的实例,只要能够把数据实时的同步到这些只读实例上,保证这这些只读实例上的数据都随时一样,这些只读的实例就可以分担大量的查询请求。
读写分离的另外一个好处是,它实施起来相对比较简单。把使用单机 MySQL 的系统升级为读写分离的多实例架构非常容易,一般不需要修改系统的业务逻辑,只需要简单修改 DAO 代码,把对数据库的读写请求分开,请求不同的 MySQL 实例就可以了。
这边有一个手写的例子,数据源配置了master、slave两个读写数据源,通过MyBatis拦截器,对sql判断是读sql还是写sql,进而选择对应的数据源。最后借助spring预留的扩展接口AbstractRoutingDataSource,其提供了动态数据源的功能,可以帮助我们实现读写分离,内部方法determineCurrentLookupKey() 可以决定最终使用哪个数据源。
通过读写分离这样一个简单的存储架构升级,就可以让数据库支持的并发数量增加几倍到十几倍。所以,当你的系统用户数越来越多,读写分离应该是你首先要考虑的扩容方案。
注意:读写分离可能会带来数据不一致问题。
主库负责处理所有的更新操作,然后异步将数据变更实时同步到所有的从库中去,这个过程有一个微小的时间差,这个时间差叫主从同步延迟。正常情况下,主从延迟非常小,不超过1ms。但即使这个非常小的延迟,也会导致在某一个时刻,主库和从库上的数据是不一致的。应用程序需要能接受并克服这种主从不一致的情况,否则就会引发一些由于主从延迟导致的数据错误。
比如:用户在淘宝下了一笔订单,当支付成功后,按理说是应该跳到订单详情页。但此时,订单从库可能还没来及的同步订单主库的最新状态,有可能仍处于“待付款”,造成不好的用户体验。所以,细心的我们会发现,大部分的电商网站支付成功后,是不会自动跳回到订单详情页,它增加了一个无关紧要的“支付成功”页面。其实这个页面没有任何有效的信息,就是告诉你支付成功,然后再放一些广告什么的。你如果想再看刚刚支付完成的订单,需要手动点一下,这样就很好地规避了主从同步延迟的问题。
3、数据归档
既然数据库的容量影响性能,那么我们可以从数据量上做优化,将一些不用的数据清理并归档。
所谓归档,其实也是一种拆分数据的策略。以电商为例,就是把大量的历史订单移到另外一张历史订单表中。为什么这么做呢?因为像订单这类具有时间属性的数据,都存在热尾效应。大多数情况下访问的都是最近的数据,但订单表里面大量的数据都是不怎么常用的老数据。
画外音:不到万不得已,先不要着急分库分表,后者的技改成本还是很大的,同时还会引入分布式事务问题,需要引入额外框架来解决,维护成本也非常高。
清理数据方案改造成本非常小,由于相对独立,与业务解耦,不需要对原来的业务代码做改动,影响面及风险会比较低。
早年像淘宝、京东大型电商网站查看自己的订单时,都有一个”三个月前订单“选项,其实就是查的订单历史表。
清理过程中需要对原表的数据删除,但是删除了大量的数据后,如果你检查一下 MySQL 占用的磁盘空间,你会发现它占用的磁盘空间并没有变小,这是什么原因呢?其实和 InnoDB 的物理存储结构有关系。
虽然逻辑上每个表都有B+ 索引树,但是物理上,每条记录都是存放在磁盘文件中的,这些记录通过一些位置指针来组成一棵 B+ 树。当 MySQL 删除一条记录的时候,只能是找到记录所在的文件中位置,然后把文件的这块区域标记为空闲,然后再修改 B+ 树中相关的一些指针,完成删除。其实那条被删除的记录还是躺在那个文件的那个位置,所以并不会释放磁盘空间。
当然如果磁盘空间紧张,可以执行一次 OPTIMIZE TABLE 释放存储空间,对于 InnoDB 来说,执行 OPTIMIZE TABLE 实际上就是把这个表重建一遍,执行过程中会一直锁表,涉及到数据库的业务操作会被卡住,使用时要特别小心。重建表的过程中,索引也会重建,这样表数据和索引数据都会更紧凑,不仅占用磁盘空间更小,查询效率也会有提升。
4、分库分表
数据库的性能取决于两个因素:查找的时间复杂度、数据量大小。解决海量数据导致存储系统慢的问题,思想非常简单,就是一个“拆”字,把海量数据拆分成 N 个分片。拆开之后,每个分片里的数据就没那么多了,然后让查找尽量落在某一个分片上,这样来提升查找性能。
分库分表的核心特点:
- 每个分表的结构都一样
- 每个分表的数据都不一样,没有交集
- 所有分表的并集是全量数据
分库分表可以解决两个问题:
- 分片查询,减少了查询的数据量。有效解决查询慢问题
- 应对高并发问题,一个数据库实例撑不住,就把并发请求分散到多个实例中去,所以,解决高并发的问题是需要分库的。
画外音:数据量大,就分表;并发高,就分库
分库分表最核心的就是选择分表键 Sharding Key,通过分表键按一定的路由分表算法(如:Hash取模分片、区间范围、查表法)指向对应的数据分片。
- Hash取模分片。比如按订单id做分表键,分1024张表,则将订单id对1024取模,得到的便是分表编号,如果还要分库,则再对分库数取整。
- 区间范围。这个比较容易理解,比如按日期分表,1年12个月,每个月的数据集中在一个表中。
- 查表法。查表法其实就是没有分片算法,决定某个 Sharding Key 落在哪个分片上,全靠人为来分配,分配的结果记录在一张表里面。每次执行查询的时候,先去表里查一下要找的数据在哪个分片中。
以电商巨头淘宝的订单表设计为例,订单涉及双向查找,有买家视角,还有卖家视角。无论选择买家uid还是卖家uid都无法满足需求,参考淘宝的做法,规则最大化适用原则,订单号拆成两部分,前面部分为全局唯一自增id,后面部分为买家id的后六位,分表键按照买家uid的后6位来计算,未来支持最大扩展100万张逻辑分表,可以支持按订单id或买家uid来查询。至于卖家部分,采用数据异构方式,采用卖家uid做分表键,将卖家uid和订单id放入另一张数据表中,满足卖家的查询。当然复杂度也会提升不少。
关于分库分表的工具市面有很多,大家可以根据自己公司的实际需要选择最适合的框架。
- sharding-sphere:jar,前身是 sharding-jdbc。
- TDDL:jar,Taobao Distribute Data Layer。
- Mycat:中间件。
本文转载自微信公众号「微观技术」
原文链接:https://mp.weixin.qq.com/s/-8PX_VPwPuq-SuqeDH1zHg.