MySQL热点行优化技术

对于秒杀热点场景,MySQL官方版本一般不超过1000 TPS每秒,在对MySQL优化前一般使用Redis或者其他NewSQL数据库来抗,但是Redis对于数据的一致性支持较差,NewSQL数据库对于运维等要求较高,小公司一般不具备相关技术。

以下列举一些互联网大厂的MySQL热点行优化技术。

 

一、热点行性能瓶颈

 

在Mysql数据库中,为了保证事务的ACID属性,当一个事务对一行数据进行更新时,会对目标数据行加锁,直到事务提交或回滚时才释放。同一时段内,对于同一个数据行,只有一个事务能够进行更新,其它事务需要等待。因此并发的对同一行进行更新操作,那么在数据库内部一定的串行进行的,更糟糕的是,这些并发的冲突事务会触发严重的死锁检测,进一步导致性能下降。

 

1、串行读写

 

Update时,where中的过滤条件列,如果用索引,锁行,无法用索引,锁表。按照索引规则,如果能使用索引,锁行,不能使用索引,锁表。

而热点行更新通常会使用锁定读,也就是 select ... for update 语句。

 

2、死锁检测

 

在库存扣减等业务中,通常需要在事务中更新不止一张表,高并发锁的争抢,极易造成死锁。

MySQL数据库通过死锁检测(innodb_deadlock_detect)和死锁超时时间(innodb_lock_wait_timeout)这两个参数来进行死锁解决。

 

在MySQL 8.0中,增加了一个新的动态变量innodb_deadlock_detect,用来控制InnoDB是否执行死锁检测。

 

该参数的默认值为ON,即打开死锁检测。开启后InnoDB在加锁的时候会检测加锁后是否会造成死锁,如果会加锁,就回滚代价最小的那一个事务。

对于高并发的系统,当大量线程等待同一个锁时,死锁检测会导致性能的下降。

此时,如果禁用死锁检测,而改为依靠参数innodb_lock_wait_timeout来释放长时间占用锁资源的事务可能会更加高效。

 

二、热点行优化技术
1、转化update为insert

比较常见的大并发场景之一就是热点数据的 update,比如具有预算类的库存、账户等。

 

update从原理上需要innodb engine 先获取row数据,然后进行row format转换到mysql服务层,再通过mysql服务器层进行数据修改,最后再通过innodb engine写回。

 

这整个过程每一个环节都有一定的开销,首先需要一次innodb查询,然后需要一次row format(如果row比较宽的话性能损失还是比较大的),最后还需要一次更新和一次写入,大概需要四个小阶段。

 

一次update就需要上述四过程开销。此时如果qps非常大,必然会有一定性能开销(这里暂不考虑cache、mq之类的削峰)。那么我们能不能将单个行的热点分散开来,同时将update转换成insert,我们来看下如何骚操作。

 

我们引入 slot 概念,原来一个row 我们通过多个row来表示,结果通过sum来汇总。为了不让slot成为瓶颈,我们 rand slot,然后将update转换成insert,通过 on duplicate key update 子句来解决冲突问题。

 

我们创建一个sku库存表。

 

CREATE TABLE `tb_sku_stock` (

  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,

  `sku_id` bigint(20) NOT NULL,

  `sku_stock` int(11) DEFAULT '0',

  `slot` int(11) NOT NULL,

  PRIMARY KEY (`id`),

  UNIQUE KEY `idx_sku_slot` (`sku_id`,`slot`),

  KEY `idx_sku_id` (`sku_id`)

) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4

 

表中唯一性索引 idx_sku_slot 用来约束同一个 sku_id 不同 slot 。

 

库存增加操作和减少操作要分开来处理,我们先看增加操作。

 

insert into tb_sku_stock (sku_id,sku_stock,slot)

values(101010101, 10, round(rand()*9)+1) 

on  duplicate key update sku_stock=sku_stock+values(sku_stock)

 

我们给 sku_id=101010101 增加10个库存,通过 round(rand()*9)+1 将slot控制在10个以内(可以根据情况放宽或缩小),当 unique key 不冲突的话就一直是insert,一旦发生 duplicate 就会执行 update。(update也是分散的)

 

我们来看下减少库存,减少库存没有增加库存那么简单,最大的问题是要做前置检查,不能超扣。

 

我们先看库存总数检查,比如我们扣减10个库存数。

 

select sku_id, sum(sku_stock) as ss

from tb_sku_stock

where sku_id= 101010101

group by sku_id having ss>= 10 for update

 

mysql的查询是使用mvcc来实现无锁并发,所以为了实时一致性我们需要加上for update来做实时检查。

如果库存是够扣减的话我们就执行 insert into select 插入操作。

 

insert into tb_sku_stock (sku_id, sku_stock, slot)

select sku_id,-10 as sku_stock,round(rand() *9+ 1)

from(

    select sku_id, sum(sku_stock) as ss

    from tb_sku_stock

    where sku_id= 101010101

    group by sku_id having ss>= 10 for update) as tmp

on duplicate key update sku_stock= sku_stock+ values(sku_stock)

 

整个操作都是在一次db交互中执行完成,如果控制好单表的数据量加上 unique key 配合性能是非常高的。

 

 

三、存储引擎层面优化

 

业界在数据库层面针对热点问题存在三种优化方案。SQL合并、事务排队、事务Batch提交。

 

 

1、SQL合并缩短锁时间

 

先写后读这种场景需要两次SQL请求,进行两次网络交互,通过SQL合并,只需要一次SQL(比如 select * from update)请求就可以完成,通过减少网络开销来达到优化性能的目的。

这种优化大致可以带来一倍的性能提升,适合轻量级热点场景。

 

 

 

在典型的库存更新流程中,一个事务中有多条语句(最少也有一个update+一个commit),这样update(减库存,开始锁表),走网络,查询结果(走网络),commit,两次跨网络调用导致update锁行比较久,于是可以新造一个语法 select update一次搞定

 

比如库存扣减的业务逻辑可以简化为下面这个事务:

(1)begin;

(2)insert 交易流水表; – 交易流水对账

(3)update 库存明细表 where id in (sku_id,item_id);

(4)select 库存明细表;

(5)commit

 

 

 

 

 

2、优化MySQLServer,对单行事务排队

优化数据库存储引擎,对单行记录并发排队写,实际上是通过提高并发的粒度,降低锁竞争和死锁检测。

 

 

热点更新导致的行锁竞争和死锁检测成本非常高,因此我们可以事先对冲突的事务进行排队,类似MySQL并行复制中的writeSet,通过排队降低锁竞争和死锁检测的开销,提升性能。这种方案大致可以提升10倍的性能。

 

拍减模式在整个交易过程中只有一次扣减交互,所以是不需要付款减库存那样的判重逻辑,就是说,拍减的减库存sql只有一条update语句就搞定了。而付减有两条,一条insert判重+一条update减库存(双十一拍减接口在高峰的rt约为8ms,而付减接口在高峰的rt约为15ms);

其次,当大量请求(线程)落到mysql的同一条记录上进行减库存时,线程之间会存在竞争关系,因为要争夺InnoDB的行锁,当一个线程获得了行锁,其他并发线程就只能等待(InnoDB内部还有死锁检测等机制会严重影响性能),当并发度越高时,等待的线程就越多,此时tps会急剧下降,rt会飙升,性能就不能满足要求了。那如何减少锁竞争?答案是:排队!库存中心从几个层面做了排队策略。

首先,在应用端进行排队,因为很多商品都是有sku的,当sku库存变化时item的库存也要做相应变化,所以需要根据itemId来进行排队,相同itemId的减库存操作会进入串行化排队处理逻辑,不过应用端的排队只能做到单机内存排队,当应用服务器数量过多时,落到db的并发请求仍然很多,所以最好的办法是在db端也加上排队策略,今年库存中心db部署了两个的排队patch,一个叫“并发控制”,是做在InnoDB层的,另一个叫“queue on pk”,是做在mysql的server层的,两个patch各有优缺点,前者不需要应用修改代码,db自动判断,后者需要应用程序写特殊的sql hint,前者控制的全局的sql,后者是根据hint来控制指定sql,两个patch的本质和应用端的排队逻辑是一致的,具体实现不同。双十一库存中心使用的是“并发控制”的patch。

2013年的单减库存TPS最高记录是1381次每秒。

对于秒杀热点场景,官方版本500tps每秒,问题在于同时涌入的请求太多,每次取锁都要检查其它等锁的线程(防止死锁),这个线程队列太长的话导致这个检查时间太长; 继续在前面增加能够进入到后面的并发数的控制,通过增加线程池、控制并发能到1400(no deadlock list check);

热点更新下的死锁检测(no deadlock list check)
由于热点更新是分布式的客户端并发的向单点的数据库进行了并行更新一条记录,到数据库最后是把并行的线程转行成串行的操作。但在串行操作的时候,由于对同一记录的锁申请列表过大,死锁检测的机制在检测锁队列的时候,反而拖慢了每一个更新。
 

 

3、特殊优化,增加新关键字,批量提交

 

参考腾讯云MariaDB https://cloud.tencent.com/document/product/237/13402

 

核心思想是:针对应用层SQL做轻量化改造,带上”热点行SQL”的hint,当这种SQL进入内核后,在内存中维护一个hash表,将主键或唯一键相同的请求(一般也就是同一商品id)hash到同一个地方做请求的合并,经过一段时间后(默认100us)统一提交,从而实现了将串行处理变成了批处理,让每个热点行更新请求并不需要都去扫描和更新btree。

 

  1. 热点的自动识别:前面已经讲过了,库存的扣减SQL都会有commit on success标记。mysql内部分为普通通道和热点扣减通道。普通通道里是正常的事务。热点通道里收集带有commit on success标记的事务。在一定的时间区间段内(0.1ms),将收集到的热点按照主键或者唯一键进行hash; hash到同一个桶中为相同的sku; 分批组提交这0.1ms收集到的热点商品。
  2. 轮询处理: 第一批进行提交时,第二批进行收集; 当第一批完成了提交开始收集时,第二批就可以进行提交了。不断轮询,提高效率

 

通过内存合并库存减操作,干到100000(每个减库存操作生成一条独立的update binlog,不影响其他业务2016年双11),实际这里还可以调整批提交时间间隔来进一步提升扣减QPS

 

事务Batch提交大概可以带来40倍的性能提升,但是这个方案存在两个比较严重的问题。

  • 热点识别是通过业务SQL注释实现的,业务需要事先确定热点Row。
  • 流水线依赖两个分组是不同的row,如果大量的非热点数据也被收集,那么会严重拖累热点的处理效率。
  • 事务Batch提交虽然极大的提升了主库的写吞吐,但是同时也产生了大量的binlog,从库没有能力即使处理,会造成比较严重的滞后。

 

 

参考:

https://plantegg.github.io/2020/11/18/MySQL%E9%92%88%E5%AF%B9%E7%A7%92%E6%9D%80%E5%9C%BA%E6%99%AF%E7%9A%84%E4%BC%98%E5%8C%96/

 

 

 

 

posted @ 2023-05-11 16:41  邴越  阅读(758)  评论(1编辑  收藏  举报