赞
踩
优化,谝一谝。查询优化或者SQL优化、索引优化、库表结构优化,或者说设计优化以及硬件优化需要齐头并进,一个不落
。并且关系型数据库和非关系型数据库肯定都涉及到了优化:
PART0:定位SQL语句的性能问题
咱们得大概先知道问题在哪吧
,语法错误或者其他什么错误才能对症下药呗。
低性能的SQL语句的定位
,最重要也是最有效的方法就是使用执行计划
,MySQL提供了explain命令来查看语句的执行计划【执行计划就是显示数据库引擎对于SQL语句的执行的详细情况,其中包含了是否使用索引,使用什么索引,使用的索引的相关信息等】
。 我们知道,不管是哪种数据库,或者是哪种数据库引擎,在对一条SQL语句进行执行的过程中都会做很多相关的优化,对于查询语句,最重要的优化方式就是使用索引
。
当执行查询时,这个标记会使其返回关于在执行计划中每一步的信息,而不是执行它
。它会返回一行或多行信息,显示出执行计划中的每一部分和执行的次序。PART1:关系型数据库优化:以MySQL为例
先处理掉那些占着连接但是不工作的线程
。或者再考虑断开事务内空闲太久的连接。kill connection + id减少连接过程的消耗以及慢查询等过程的优化:
:慢查询性能问题在 MySQL 中主要是指会引发性能问题的慢查询【要找到最需要优化的 SQL 语句。要么是使用最频繁的语句,要么是效率较低的SQL语句并且优化后提高最明显的语句
,可以通过查询 MySQL 的慢查询日志来发现需要进行优化的 SQL 语句
】
一般查询耗时超过 1 秒的 SQL 都被称为慢 SQL
,有的公司运维组要求的可能更加严格,比如如果 SQL 的执行耗时超过 0.2s,也被称为慢 SQL,必须在限定的时间内尽快优化,不然可能会影响服务的正常运行和用户体验。
show variables like 'slow_query_log%'
命令,如下:先使用慢查询日志功能
,查询出比较慢的 SQL 语句,然后再通过 Explain 来查询 SQL 语句的执行计划【MySQL将显示来自优化器的有关语句执行计划的信息,即MySQL解释了它将如何处理该语句,包括有关如何连接表以及以何种顺序连接表等信息】,最后分析并定位出问题的根源,再进行处理
。type:表示连接类型
,查看索引执行情况的一个重要指标:system > const > eq_ref > ref > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL
rows列表示MySQL估算要找到我们所需的记录,需要读取的行数
。对于InnoDB表,此数字是估计值,并非一定是个准确值filtered列是一个百分比的值,表里符合条件的记录数的百分比
。简单点说,这个字段表示存储引擎返回的数据在经过过滤后,剩下满足条件的记录数量的比例key列表示实际用到的索引
。一般配合possible_keys列一起看explain只是看到SQL的预估执行计划,如果要了解SQL真正的执行线程状态及消耗的时间,需要使用profiling。开启profiling参数后,后续执行的SQL语句都会记录其资源开销,包括IO,上下文切换,CPU,内存等等,我们可以根据这些开销进一步分析当前慢SQL的瓶颈再进一步进行优化
。profiling默认是关闭,我们可以使用show variables like '%profil%'
查看是否开启show profiles会显示最近发给服务器的多条语句
,条数由变量profiling_history_size定义,默认是15。如果我们需要看单独某条SQL的分析,可以show profile查看最近一条SQL的分析,也可以使用show profile for query id(其中id就是show profiles中的QUERY_ID)查看具体一条的SQL语句分析。除了查看profile ,还可以查看cpu和ioprofile只能查看到SQL的执行耗时,但是无法看到SQL真正执行的过程信息,即不知道MySQL优化器是如何选择执行计划
。这时候,我们可以使用Optimizer Trace,它可以跟踪执行语句的解析优化执行的全过程。
set optimizer_trace="enabled=on"
打开开关,接着执行要跟踪的SQL,最后执行select * from information_schema.optimizer_trace跟踪在测试库上测试其耗时
,慢查询的统计主要由运维在做,会定期将业务中的慢查询反馈给我们。慢查询的优化首先要搞明白慢的原因是什么? 是查询条件没有命中索引?是load了不需要的数据列?还是数据量太大?
。所以优化也是针对这三个方向来的:
优化方向一
:首先分析 语句
,看看 是否load了额外的数据
,可能是查询了多余的行并且抛弃掉了,可能是加载了许多结果中并不需要的列,对语句进行分析以及重写。【SQl没办法很好优化,可以改用ES的方式,或者数仓。】是否在检索大量超过需要的数据,可能是太多行或列
。要尽量避免使用select *,而是查询需要的字段,这样可以提升速度,以及减少网络传输的带宽压力
select * 进行查询时,很可能就不会使用到覆盖索引了,就会造成回表查询
。因为子查询是嵌套查询,而嵌套查询会新创建一张临时表,而临时表的创建与销毁会占用一定的系统资源以及花费一定的时间
,同时对于返回结果集比较大的子查询,其对查询性能的影响更大sql select name from A where id in (select id from B);
因为数据是无序的,所以就需要排序。如果数据本身是有序的,那就不会再用到文件排序啦。而索引数据本身是有序的,我们通过建立索引来优化order by语句
。rowid排序,一般需要回表去找满足条件的数据,所以效率会慢一点
。以下这个SQL,使用rowid排序:select name,age,city from staff where city = ‘深圳’ order by age limit 10;group by使用不当,很容易就会产生慢SQL问题。因为它既用到临时表,又默认用到排序。有时候还可能用到磁盘临时表
。select * from table where type = 2 and level = 9 order by id asc limit 190289,10;
select a.* from table a, (select id from table where type = 2 and level = 9 order by id asc limit 190289,10 ) b where a.id = b.id
select * from table where id > (select * from table where type = 2 and level = 9 order by id asc limit 190289, 1) limit 10;
因为Mysql并非是跳过偏移量直接去取后面的数据,而是先把偏移量+要取的条数,然后再把前面偏移量这一段的数据抛弃掉再返回的
。这条语句需要 load1000000 数据然后基本上全部丢弃,只取 10 条当然比较慢
. 当时我们 可以修改为select * from table where id in (select id from table where age > 20 limit 1000000,10).这样虽然也 load 了一百万的数据,但是由于索引覆盖,要查询的所有字段都在索引中,所以速度会很快
。嵌套循环
,简单来说,就是遍历驱动表(外层表),每读出一行数据,取出连接字段到被驱动表(内层表)里查找满足条件的行,组成结果行
。要提升join语句的性能,就要尽可能减少嵌套循环的循环次数
。
小表做驱动表【小的循环在外层,对于数据库连接来说就只连接5次,进行5000次操作,如果1000在外,则需要进行1000次数据库连接,从而浪费资源,增加消耗。这就是为什么要小表驱动大表。】
。如果难以判断哪个是大表,哪个是小表,可以用inner join连接,MySQL会自动选择小表去驱动大表
Inner join 、left join、right join,优先使用Inner join【如果inner join是等值连接,或许返回的行数比较少,所以性能相对会好一点。】
,如果要使用left join,左边表数据结果尽量小,如果有条件的尽量放到左边处理。【使用了左连接,左边表数据结果尽量小,条件尽量放到左边处理,意味着返回的行数可能比较少。】
增大 join buffer 的大小
,避免使用JOIN关联太多的表:对于 MySQL 来说,是存在关联缓存的,缓存的大小可以由join_buffer_size参数进行设置。在 MySQL 中,对于同一个 SQL 多关联(join)一个表,就会多分配一个关联缓存,如果在一个 SQL 中关联的表越多,所占用的内存也就越大。如果程序中大量的使用了多表关联的操作,同时join_buffer_size设置的也不合理的情况下,就容易造成服务器内存溢出的情况,就会影响到服务器数据库性能的稳定性不要用 * 作为查询列表,只返回需要的列
利用索引扫描做排序,在设计索引时,尽可能使用同一个索引既满足排序又用于查找行
。MySQL有两种方式生成有序结果:其一是对结果集进行排序的操作,其二是按照索引顺序扫描得出的结果自然是有序的。但是如果索引不能覆盖查询所需列,就不得不每扫描一条记录回表查询一次,这个读操作是随机IO,通常会比顺序全表扫描还慢--建立索引(date,staff_id,customer_id),只有当索引的列顺序和ORDER BY子句的顺序完全一致,并且所有列的排序方向都一样时,才能够使用索引来对结果做排序
select staff_id, customer_id from test where date = '2010-01-01' order by staff_id,customer_id;
先创建临时表
,然后将各个查询结果填充到临时表中最后再来做查询,很多优化策略在union查询中都会失效
,因为它无法利用索引。最好手工将where、limit等子句下推到union的各个子查询中,以便优化器可以充分利用这些条件进行优化
。此外,除非确实需要服务器去重,一定要使用union all,如果不加all关键字,MySQL会给临时表加上distinct选项,这会导致对整个临时表做唯一性检查,代价很高
应当指定列为not null,用0、空串或其他特殊的值代替空值,比如定义为int not null default 0
如果把它们的name字段改为编码一致,相同的SQL,还是会走索引
。对于存储和计算来说,int(1)和int(20)是相同的
在实际使用中,要慎用这两种类型,它们的查询效率很低,如果字段必须要使用这两种类型,可以把此字段分离成子表,需要查询此字段时使用联合查询,这样可以提高主表的查询效率
优化方向二
:分析语句的执行计划,然后获得其使用索引的情况,之后修改语句或者修改索引,使得语句可以尽可能的命中索引【多数慢SQL都跟索引有关,比如不加索引,索引不生效、不合理等,这时候,我们可以优化索引。】
我们就需要给最常使用的查询字段上,添加相应的索引,这样才能提高查询的性能
但是如果索引的叶节点中已经包含要查询的字段,那它没有必要再回表查询了
,这就叫覆盖索引select name from test where city='上海'
//我们将被查询的字段建立到联合索引中,这样查询结果就可以直接从索引中获取
alter table test add index idx_city_name (city, name);
in 的值不要超过 500 个,in 操作可以更有效的利用索引,or 大多数情况下很少能利用到索引
注意in后面的元素不要过多哈
。in元素一般建议不要超过200个,如果超过了,建议分组,每次200一组进行哈:select user_id,name from user where user_id in (1,2,3...200)
;
select user_id,name from user where user_id in (1,2,3...1000000)
,如果我们对in的条件不做任何限制的话,该查询语句一次性可能会查询出非常多的数据,很容易导致接口超时。尤其有时候,我们是用的子查询,in后面的子查询,你都不知道数量有多少那种,更容易采坑.如下这种子查询:所以解决方法就是通过把不等于操作符改成or,可以使用索引,避免全表扫描。例如,把column<>’aaa’,改成column>’aaa’ or column<’aaa’,就可以使用索引了
使用or可能会使索引失效,从而全表扫描
。所以我们可以适当的选择使用前缀索引,以减少空间的占用和提高查询效率;使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。比如,邮箱的后缀都是固定的“@xxx.com”,那么类似这种后面几位为固定值的字段就非常适合定义为前缀索引。需要注意的是,前缀索引也存在缺点,MySQL无法利用前缀索引做order by和group by 操作,也无法作为覆盖索引
alter table test add index index2(email(6));
select * from test where id + 1 = 50;
select * from test where month(updateTime) = 7;
//一个很容易踩的坑:隐式类型转换:skuId这个字段上有索引,但是explain的结果却显示这条语句会全表扫描,原因在于skuId的字符类型是varchar(32),比较值却是整型,故需要做类型转换
select * from test where skuId=123456
一般遵循最左匹配原则【联合索引不满足最左原则,索引一般会失效,但是这个还跟Mysql优化器有关的。】
。当我们创建一个联合索引的时候,如(k1,k2,k3),相当于创建了(k1)、(k1,k2)和(k1,k2,k3)三个索引,这就是最左匹配原则。
当我们使用,姓名+年龄+性别、姓名+年龄、姓名等这种最左前缀查询条件时,就会触发联合索引进行查询;然而如果非最左匹配的查询条件,例如,性别+姓名这种查询条件就不会触发联合索引,换句话说就是索引不生效
没有特殊要求时所有表必须使用 InnoDB 存储引擎
当delete遇到in子查询时,即使有索引,也是不走索引的。而对应的select + in子查询,却可以走索引
。优化方向三
:如果对语句的优化已经无法进行,可以考虑表中的数据量是否太大,如果是的话可以进行横向或者纵向的分表
。
大表怎么优化?某个表有近千万数据,CRUD比较慢,如何优化?分库分表了是怎么做的?分表分库了有什么问题?有用到中间件么?
会堵住 MySQL 的查询过程,但是不会把内存打爆
。
适当分表、分库策略
:
可以尝试将一张大表拆分为多张子表
,把使用比较高频的主信息放入主表中,其他的放入子表,这样我们大部分查询只需要查询字段更少的主表就可以完成了,从而有效的提高了查询的效率
select * from xxx表 order by id limit 起点值,100
】位置越大,分页查询效率成倍的下降,当起点位置在 1000000 以上的时候,对于百万级数据体量的单表,查询耗时基本上以秒为单位。而且很关键的点是 这还只是数据库层面的耗时,还没有算后端服务的处理链路时间,以及返回给前端的数据渲染时间,以百万级的单表查询为例,如果数据库查询耗时 1 秒,再经过后端的数据封装处理,前端的数据渲染处理,以及网络传输时间,没有异常的情况下,差不多在 3~4 秒之间
。
select id,name,balance from account where create_time> '2020-09-19' limit 100000,10
;)SQL的执行流程是这样的:limit语句会先扫描offset+n行,然后再丢弃掉前offset行,返回后n行数据
。也就是说limit 100000,10,就会扫描100010行,而limit 0,10,只扫描10行。【limit 100000,10 扫描更多的行数,也意味着回表更多的次数
。】将select *改成select id
,通过简化返回的字段,可以很显著的成倍提升查询效率。实际的操作思路就是先通过分页查询满足条件的主键 ID,然后通过主键 ID 查询部分数据,可以显著提升查询效果
。这种方案还是非常可行的,如果当前业务对排序要求不多,可以采用这种方案,性能也非常杠
。
但是如果当前业务对排序有要求,比如通过客户最后修改时间、客户最后下单时间、客户最后下单金额等字段来排序,那么上面介绍的【方案一】,比【方案二】查询效率更高
!select acct1.id,acct1.name,acct1.balance FROM account acct1 INNER JOIN (SELECT a.id FROM account a WHERE a.create_time > '2020-09-19' limit 100000, 10) AS acct2 on acct1.id= acct2.id;
比较好的解决办法就是将订单数据存储到 elasticSearch 中,通过 elasticSearch 实现快速分页和搜索,效果提升也是非常明显
。感觉其实差不多,咱们在Java中一般测试哪个语句或者哪个算法快不快慢不慢,不就是开始测一下时间并且结束测一下时间,减一下就知道你这个花费了多少时间了呗。一样,咱们MySQL这里也可以测量服务器的时间咋花费的,花在哪里了…在具体一点,就是数据库服务器的性能用查询的响应时间来度量,单位是每个查询花费的时间。
如果要优化任务的执行时间,最好的办法是通过测量定位不同的子任务花费的时间,然后优化去掉一些子任务、降低子任务的执行频率或者提升子任务的效率
。先用操作系统命令 top 命令观察是不是 MySQLd 占用导致的,如果不是,找出占用高的进程,并进行相关处理
。如果是 MySQLd 造成的, show processlist,看看里面跑的 session 情况,是不是有消耗资源的 sql 在运行
。找出消耗高的 sql,看看执行计划是否准确, index 是否缺失,或者实在是数据量太大造成
。
把连接表拆开成较小的几个执行,可读性更高
。如果一定需要连接很多表才能得到数据,那么意味着糟糕的设计了PART2-1:非关系型数据库优化:以Redis为例
Redis的慢查询日志功能用于记录执行时间超过给定时长的命令请求
,用户可以通过这个功能产生的日志来监视和优化查询速度。
先进先出
的方式保存多条慢查询日志,当服务器存储的慢查询日志数量等于slowlog-max-len选项的值时,服务器在添加一条新的慢查询日志之前,会先将最旧的一条慢查询日志删除。Redis服务器将所有的慢查询日志保存在服务器状态的slowlog链表中,每个链表节点都包含一个slowlogEntry结构,每个slowlogEntry结构代表一条慢查询日志
。struct redisServer { ... //下一条慢查询日志的ID 。slowlog_entry_id属性的初始值为0,每当创建一条新的慢查询日志 时,这个属性的值就会用作新日志的id值,之后程序会对这个属性的值增一。 long long slowlog_entry_id; //保存了所有慢查询日志的链表 。slowlog链表保存了服务器中的所有慢查询日志,链表中的每个节点 都保存了一个slowlogEntry结构,每个slowlogEntry结构代表一条慢查询 日志: list *slowlog; //服务器配置slowlog-log-slower-than 选项的值 long long slowlog_log_slower_than; // 服务器配置slowlog-max-len选项的值 unsigned long slowlog_max_len; ... }; typedef struct slowlogEntry { //唯一标识符 long long id; //命令执行时的时间,格式为UNIX 时间戳 time_t time; //执行命令消耗的时间,以微秒为单位 long long duration; //命令与命令参数 robj **argv; //命令与命令参数的数量 int argc; } slowlogEntry; ``` ![在这里插入图片描述](https://img-blog.csdnimg.cn/cd26d5abdcdb4bc8a835c0feb3606d59.png)
打印和删除慢查询日志可以通过遍历slowlog链表来完成,slowlog链表的长度就是服务器所保存慢查询日志的数量。
):在每次执行命令的之前和之后,程序都会记录微秒格式的当前 UNIX时间戳,这两个时间戳之间的差就是服务器执行命令所耗费的时长,服务器会将这个时长作为参数之一传给slowlogPushEntryIfNeeded函数,而slowlogPushEntryIfNeeded函数则负责检查是否需要为这次执行的命令创建慢查询日志
Redis 以 redisDb为中心存储键值对形式的数组
】, Redis 需要先申请内存,数据过期或者内存淘汰需要回收内存,从而拓展出内存碎片优化。Redis 使用dict结构来保存所有的键值对(key-value)数据
,这是一个全局哈希表,所以对 key 的查询能以 O(1) 时间得到。key 的哈希值最终会映射到 ht_table 【ht_table[2]是dict结构中的两个dictEntry类型的全局哈希表指针数组,与渐进式 rehash 有关,dictEntry 类型的ht_table 数组每个位置我们也叫做哈希桶,就是这玩意保存了所有键值对
**。】的一个位置,如果发生哈希冲突,则拉出一个哈希链表。哈希桶的每个元素的结构由 dictEntry 定义
:这个结构中的key 指向键值对的键的指针,key 都是 string 类型【咱们Redis的key不都是字符串对象嘛,这不刚好对上了】
【void *key 和 void *value 指针指向的是 redisObject,Redis 中每个对象都是用 redisObject 表示。】而是指向具体值的指针
,从而实现了哈希桶能存不同数据类型的需求。哈希桶中,键值对的值都是由一个叫做 redisObject 的对象定义
【**void key 和 void value 指针指向的是 redisObject,Redis 中每个对象都是用 redisObject 表示。】set key value 的命令,*key指针指向 SDS 字符串保存 key,而 value 的值保存在 *ptr 指针指向的数据结构
,消耗的内存:key + value。降低 Redis 内存使用的最粗暴的方式就是缩减键(key)【对于 key 的命名使用「业务模块名:表名:数据唯一id」这样的方式方便定位问题,所以对于 key 的优化就是使用单词简写方式优化内存占用】与值(value)【value优化的话主要就是:不要大而全的一股脑将所有信息保存,想办法去掉一些不必要的属性,比如缓存登录用户的信息,通常只需要存储昵称、性别、账号等;比如用户的会员类型:0 表示「屌丝」、1 表示 「VIP」、2表示「VVIP」。而不是存储 VIP 这个字符串;对数据的内容进行压缩,比如使用 GZIP、Snappy;使用性能好,内存占用小的序列化方式。比如 Java 内置的序列化不管是速度还是压缩比都不行,我们可以选择 protostuff,kryo等方式】的长度
。key 对象都是 string 类型,value 对象主要有五种基本数据类型:String、List、Set、Zset、Hash【同一数据类型会根据键的数量和值的大小也有不同的底层编码类型实现,其中ziplist 压缩列表由 quicklist 代替(3.2 版本引入),而双向链表由 listpack 代替】
。考虑了综合平衡空间碎片和读写性能两个维度所以使用了新编码 quicklist
。【由于目前大部分Redis运行的版本都是在3.2以上,所以 List 类型的编码都是quicklist,考虑了综合平衡空间碎片和读写性能两个维度所以使用了新编码 quicklist。】整数我们经常在工作中使用,Redis 在启动的时候默认生成一个 0 ~9999 的整数对象共享池用于对象复用,减少内存占用
。【如果 value 可以使用整数表示的话尽可能使用整数,这样即使大量键值对的 value 大量保存了 0~9999 范围内的整数,在实例中,其实只有一份数据。】
String 类型除了记录实际数据以外,还需要额外的内存记录数据长度、空间使用等信息
】。Bitmap 的底层数据结构用的是 String 类型的 SDS 数据结构来保存位数组,Redis 把每个字节数组的 8 个 bit 位利用起来
,每个 bit 位 表示一个元素的二值状态(不是 0 就是 1)。8 个 bit 组成一个 Byte,所以 Bitmap 会极大地节省存储空间。 这就是 Bitmap 的优势。比如说系统中有一个用户对象,我们不需要为一个用户的昵称、姓名、邮箱、地址等单独设置一个 key,而是将这个信息存放在一个哈希表里
。使用 String 类型,为每个属性设置一个 key 会占用大量内存【因为 Redis 的数据类型有很多,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等)。所以,Redis 会用一个 RedisObject 结构体来统一记录这些元数据,用 *prt 指针指向实际数据。当我们为每个属性都创建 key,就会创建大量的 redisObejct 对象占用内存
。】两个层面原因导致内存碎片的形成
内存分配策略决定了无法做到按需分配
。因为分配器是按照固定大小来分配内存。PART2-2:引入了缓存之后,就得考虑缓存和数据库数据不一致问题,如何解决?
无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象
。】数据库和缓存的数据不一致
的问题,造成缓存和数据库的数据不一致的现象,是因为并发问题【比如请求 A 和请求 B 两个请求
,同时更新同一条
数据,则可能出现这样的顺序:A 请求先将数据库的数据更新为 1,然后在更新缓存前
,请求 B手来的快,将数据库的数据更新为 2,紧接着也把缓存更新为 2,然后 A 请求更新缓存为 1。此时,数据库中的数据是 2,而缓存中的数据却是 1,出现了缓存和数据库中的数据不一致的现象
】请求 A 和请求 B 两个请求
,同时更新同一条
数据,则可能出现这样的顺序:A 请求先将缓存的数据更新为 1,然后在更新数据库前,B 请求来了, 将缓存的数据更新为 2,紧接着把数据库更新为 2,然后 A 请求将数据库的数据更新为 1。此时,数据库中的数据是 1,而缓存中的数据却是 2,出现了缓存和数据库中的数据不一致的现象
。】先更新数据库,然后直接删除缓存
:这个策略是有名字的,是叫 Cache Aside 策略,中文是叫旁路缓存策略
在更新数据时,不更新缓存,而是删除缓存中的数据
。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中
。
先更新数据库
中的数据,然后删除缓存
中的数据】
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题
先更新数据库 + 再删除缓存的方案,是可以保证数据一致性的。此外,为了确保万无一失,我们还可以给缓存数据加上过期时间,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。
】:假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高【请求 1 从 db 读数据 A-> 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -> 请求 1 将数据 A 写入 cache】
先更新数据库, 再删除缓存其实是两个操作
,前面的所有分析都是建立在这两个操作都能同时执行成功,而这次客户投诉的问题就在于,在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值。【如果在之前给缓存加上了过期时间,会出现过一段时间才更新生效的现象,假设如果没有这个过期时间的兜底,那后续的请求读到的就会一直是缓存中的旧数据(举个例子,比如应用要把数据 X 的值从 1 更新为 2,先成功更新了数据库,然后在 Redis 缓存中删除 X 的缓存,但是这个操作却失败了,这个时候数据库中 X 的新值为 2,Redis 中的 X 的缓存值为 1,出现了数据库和缓存数据不一致的问题。后续有访问数据 X 的请求,会先在 Redis 中查询,因为缓存并没有 诶删除,所以会缓存命中,但是读到的却是旧值 1。),这样问题就更大了
】
其实不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。有两种解决办法
:
增加 cache 更新重试机制(常用): 如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将缓存中对应的 key 删除即可
。】更新数据库成功,就会产生一条变更日志,记录在 binlog 里
。我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的
**。所以,如果我们的业务对缓存命中率有很高的要求
,我们可以采用更新数据库 + 更新缓存的方案,因为更新缓存并不会出现缓存未命中的情况。这个方案在两个更新请求并发执行的时候,会出现数据不一致的问题
,因为更新数据库和更新缓存这两个操作是独立的,而我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据的不一致。所以我们得增加一些手段来解决这个问题,这里提供两种做法:
在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存
,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期
,对业务还是能接受的。命中了缓存
,则直接返回
数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存
,并且返回给用户三种模式各有优劣,不存在最佳模式,根据具体的业务场景选择适合自己的缓存读写模式
】
这种缓存读写策略在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 db 的功能
。
在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的
Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db
。很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量
PART3:不仅仅局限于代码层面的性能优化,性能优化通常是“时间”与“空间”的互换与取舍。
高性能软件系统也意味着更高的实现成本,有时候与其他质量属性甚至会冲突。
,比如安全性、可扩展性、可观测性等等。鱼和熊掌不可得兼哦
。所以大部分时候我们需要的是:在业务遇到瓶颈之前,利用常见的技术手段将系统优化到预期水平,
。从以下几个方面来说:上面关系型数据库和非关系型数据库中也已经提到了一部分嘞。也算是对上面的一个总结回顾咯
索引的原理是拿额外的存储空间换取查询时间
,增加了写入数据的开销,但使读取数据的时间复杂度一般从O(n)降低到O(logn)甚至O(1)。
是用自增长的ID还是随机的UUID做主键
?【自增长ID的性能最高,但不好做分库分表后的全局唯一ID,自增长的规律可能泄露业务信息;而UUID不具有可读性且太占存储空间
。】争执的结果就是找 一个兼具二者的优点的折衷方案
:用雪花算法生成分布式环境全局唯一的ID作为业务表主键,性能尚可、不那么占存储、又能保证全局单调递增,但引入了额外的复杂性,再次体现了取舍之道
。一个“时间换空间”的办法
。压缩的原理消耗计算的时间,换一种更紧凑的编码方式来表示数据
是拿额外的存储空间换取查询时间
对象重用的池化技术,也可以看作是一种缓存的变体【在需要某个资源时从现有的池子里直接拿一个,稍作修改或直接用于另外的用途,池化重用也是性能优化常见手段】
。常见的诸如JVM,V8这类运行时的常量池、数据库连接池、HTTP连接池、线程池、Golang的sync.Pool对象池等等。当可以猜测出以后的某个时间很有可能会用到某种数据时,把数据预先取到需要用的地方,能大幅度提升用户体验或服务端响应速度【是否用预取模式就像自助餐餐厅与厨师现做的区别,在自助餐餐厅可以直接拿做好的菜品,一般餐厅需要坐下来等菜品现做。】
。
正如烤箱预热需要消耗时间和额外的电费,在软件代码中做预取/预热的副作用通常是启动慢一些、占用一些闲时的计算资源、可能取到的不一定是后面需要的
预取是事先花时间做,削峰填谷是事后花时间做
。就像三峡大坝可以抗住短期巨量洪水,事后雨停再慢慢开闸防水。软件世界的“削峰填谷”是类似的,只是不是用三峡大坝实现,而是用消息队列、异步化等方式
。
做基准测试的,不能一概而论
。而批量处理的副作用在于:处理逻辑会更加复杂,尤其是一些涉及事务、并发的问题;需要用数组或队列用来存放缓冲一批数据,消耗了额外的存储空间。巨人的肩膀:
高性能mysql
mysql技术内幕
月伴飞鱼
mysql中文文档
码哥字节
javaguide
捡田螺的小男孩
程序员田螺
微观技术老师关于性能优化的全面的文章
捡田螺的小男孩老师的关于书写高质量SQL的30条建议
捡田螺的小男孩老师的设计MySQL表的经验准则
Java极客技术:作者鸭血粉丝Tang老师
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。