写冲突场景下的悲观 / 乐观事务模型选择
作者: 代晓磊 _Mars 原文来源:https://tidb.net/blog/e1ef3ac3
写冲突场景下的悲观 / 乐观事务模型选择
乐观 / 悲观模型是数据库常用的 2 种事务模型,首先从字义上对 2 种事务模型进行简单区分,比如我要更新 Table 中的一行数据,乐观模型就是“乐观”的认为不会有其他事务也同时更新同一行数据,所以拿数据时不用加锁,最后在提交时才判断下是否有同时更新的事务。悲观模型就是“悲观”的认为会有其他事务会更新我要更新的数据,所以会在拿数据时就会加锁,这样保证自己更新时数据不会被别人再修改。
乐观 / 悲观事务模型的大部分使用场景:读多写少 + 写冲突少用乐观模型,写多读少 + 写冲突多用悲观模型。如果事务模型选择的“不合适”,比如高并发 + 写冲突高的情况下采用乐观模式,大量的 retry 将会带来写入的性能严重下降。本文会结合具体业务场景中遇到的写入性能瓶颈,结合 TiDB 的乐观事务和悲观事务,通过压测数据给出合适的选择。
关键词:分区表,写冲突,悲观 / 乐观事务
业务场景介绍
业务简介
广告主实时监控业务,提供了广告的各个维度的消费监控数据,会以报表的方式在广告主报表平台提供给广告主实时查看,使得金主们能及时查看到广告的效果以及预算花费情况,360 内部的产品和运营也会及时关注消费和广告效果数据,通过监控可以及时的发现和反馈问题,对广告投放或者推荐算法调优后实现广告主化最少的钱到达最好的广告效果。
业务架构
API 日志 (各种广告 web 节点和曝光日志 /SDK 日志)->kafka->Flink->TiDB
写入流程是:业务数据首先进入 Kafka,每 10~30 秒会有程序读 Kafka 数据,并通过 flink 聚合后生成当前批次的数据写入到 TiDB 中,每批次会有几十万的写入,采用的是 TiDB 按时间的分区表才存数据,一般保持 30 天,单分区数据量千万~亿。
业务的写入瓶颈
业务写入 SQL 主要是:insert on duplicate key update,Batch size 为 50(相当于单 SQL 一次 insert 50 个 values),单表写入并发 10~30。insert on duplicate key update 在 insert 时一旦表中有了跟唯一索引匹配的数据,就触发 update 操作。在分区写入初期由于没有重复的 uniq_key,所以主要是 insert。随着数据量增加,update 的操作也越来越多,insert:update 大概 1:10+。
虽然写入 TiDB 之前有一层 flink 聚合操作保证单批次的 insert SQL 中不会有冲突的记录,但是由于有多个写入流,多个写入流之间不保证没有 duplicate key,并且单批次的聚合时间在秒级别,如果单批次的数据没有及时写入 TiDB,下一个批次的数据也来了,这时 TiDB 中就有大量的写冲突的 SQL 在执行。
上面描述的 SQL 使用以及多并发写入流导致的写冲突,非常容易引发在流量高峰时 TIDB 的写入性能下降,导致 kafka 的 lag 挤压,客户不能及时看到广告的效果,影响广告主的投放,从而影响公司的收入。
TiDB 乐观事务模型
TiDB 中分布式事务实现一直使用的是 Percolator 的模型,采用 2PC(两阶段提交) 实现,2PC 主要分 Prewrite 和 commit 这 2 个阶段。大概的流程是:客户端开启事务,执行 DML,客户端发起 commit,下面开始 2PC 的 prewrite 阶段:TiDB 从当前要写入的数据中选择一个 Key 作为当前事务的 Primary Key,TiDB 并发地向所有涉及的 TiKV 发起 prewrite 请求,TIKV 则进行检测后将满足条件的数据加锁。TIDB 收到 prewrite 成功响应,TIDB 向 PD 请求事务 commit_ts,下面进入 2PC 的 commit 阶段:TIKV 收到 commit 操作并 check 后执行 commit(keys,commit_ts),并且清理 prewrite 的锁,TIDB 收到 2PC commit 成功的信息并且反馈给客户端。
优点:事务最终提交 commit 时才会检测冲突,在事务提交的过程中锁检测的代价是比较大的,所以乐观事务在一些场景有较好的写入提升。比如基于 id 自增主键的写入情景,或者有唯一索引但是很少或者不会出现多个并发同时对同一个行的 DML 操作的情景。
缺点:事务冲突不可避免,乐观模式采用了内部重试功能。由下面 2 个参数控制:
tidb_disable_txn_auto_retry:这个参数控制是否自动重试,默认为 1,即不重试。
tidb_retry_limit:用来控制重试次数,默认 10 次,注意只有第一个参数启用时该参数才会生效。
重试的好处,写冲突的情况避免直接报错给 client。重试的缺点:每次重试时间间隔会逐渐变长,写冲突高的情况下,一条 SQL 可能需要较长时间才能写入成功,另外 TiDB 默认不进行事务重试,因为重试事务可能会导致更新丢失,从而破坏可重复读的隔离级别。
注意:
TiDB 从 3.0 才支持悲观事务,所以在 V3.0.8 之前都是默认乐观模式
如果集群是 3.0.8 之前的版本,升级到 3.0.8+ 版本也是采用之前版本默认的乐观模式。
TiDB 悲观事务模型
TiDB 在乐观事务模型的基础上支持了悲观事务模型,将上锁的时机提前到进行 DML 时。TiDB 的悲观锁实现的原理确实如此,在一个事务执行 DML (UPDATE/DELETE) 的过程中,TiDB 不仅会将需要修改的行在本地缓存,同时还会对这些行直接上悲观锁,这里的悲观锁的格式和乐观事务中的锁几乎一致,但是锁的内容是空的,只是一个占位符,待到 Commit 的时候,直接将这些悲观锁改写成标准的 Percolator 模型的锁,后续流程跟乐观模型一样。
开启条件:
(1)TiDB V3.0.8+ 新集群,或者 tidb_txn_mode = ‘pessimistic’;
(2)业务端写入时采用“显示事务”(通过 [BEGIN|START TRANSACTION]/COMMIT 语句定义事务的开始和结束)。
注意:要想使用 TiDB 的悲观模式上面 2 个条件缺一不可。
写冲突情况下的测试流程
测试环境
TIDB 版本:V4.0.9
集群配置:2tidb+2pd+3tikv(tidb/pd 共用服务器)
硬件配置:
cpu:E5-2630v2 2;
*mem:16G DDR3*8;
disk: 系统盘 300G Intel S3500 SATA SSD1; 数据盘 flash: 曙光 INTEL P4600 2.0T 1;
net: 板载双网口千兆电网卡 1;
*power: 单电源 *1;
测试用例
分区表
`CREATE TABLE dxl_test_cost (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT ‘自增 id’,
metric_request_all bigint(20) NOT NULL DEFAULT ‘0’,
metric_clicks bigint(20) NOT NULL DEFAULT ‘0’,
metric_views bigint(20) NOT NULL DEFAULT ‘0’,
/ 此处省略多个字段
dsp_type tinyint(4) unsigned NOT NULL DEFAULT ‘0’,
place_id int(11) unsigned NOT NULL DEFAULT ‘0’,
adspace_id int(11) unsigned NOT NULL DEFAULT ‘0’,
service_type tinyint(4) unsigned NOT NULL DEFAULT ‘0’,
adspace_prop int(11) unsigned NOT NULL DEFAULT ‘0’,
deal_mode tinyint(4) NOT NULL DEFAULT ‘-1’,
gray_version tinyint(4) NOT NULL DEFAULT ‘-1’,
user_client_category tinyint(4) unsigned NOT NULL DEFAULT ‘0’,
publisher_id bigint(10) NOT NULL DEFAULT ‘0’,
create_date date NOT NULL DEFAULT ‘2000-01-01’,
time_solt time NOT NULL DEFAULT ‘-01:00:00’,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id,create_date) ,
UNIQUE KEY idx_product_metric (adspace_id,time_solt,dsp_type,deal_mode,user_client_category,gray_version,create_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin AUTO_INCREMENT=25864263174
PARTITION BY RANGE COLUMNS(create_date) (
PARTITION p20210410 VALUES LESS THAN (“20210411”),
PARTITION p20210411 VALUES LESS THAN (“20210412”),
/ 次数省略了按天的分区
PARTITION p20210508 VALUES LESS THAN (“20210509”),
PARTITION p20210509 VALUES LESS THAN (“20210510”)
);
写入SQL列举:
INSERT INTO dxl_test_cost
(
create_date,
time_solt,
dsp_type,
place_id,
adspace_id,
service_type,
adspace_prop,
deal_mode,
gray_version,
user_client_category,
publisher_id,
metric_request_all,
/ 此处省略了多个指标字段
metric_clicks,
metric_views
) VALUES (‘2021-05-06’,‘16:10’,1,xx,xxx,1,1,0,0,2,xxx,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0)
。。。。。。。。。。。。。。50 条 values
,(‘2021-05-06’,‘16:15’,1,xxx,xx,1,1,0,0,2,xxxx,0,0,0,0,0,0,0,0,0,0,0,0,0,33,0,0)
,(‘2021-05-06’,‘16:15’,1,xxx,xxx,1,1,0,1,2,xxxxx,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0)
ON DUPLICATE KEY UPDATE metric_request_all=metric_physical_request_all+values(metric_request_all),
/ 此处省略了多个指标更新
metric_clicks=metric_clicks+values(metric_clicks),
metric_views=metric_views+values(metric_views)
`
测试过程
(1)10 个并发;默认乐观提交:tidb_txn_mode=pessimistic + Autocommit
结果:QPS 平均 8,最高 50 左右,SQL 执行时间 2~10s,其实从 tidb 的日志来看有大量的 SQL retry 操作导致的写入性能不好。
注:本次乐观模式测试开启了 tidb 本身的自动重试 (tidb_disable_txn_auto_retry=0,并且重试次数为 10),目的就是业务端默认重试 3 次,没有开启重试前,业务重试 3 次也写入不成功,导致大量的写入失败,需要靠 tidb 的自动重试来保证数据能写入。
(2)10 个并发;悲观模式提交:tidb_txn_mode=pessimistic + Autocommit=off(开启显示事务)
悲观模式下 10 个并发的写入性能:QPS 平均 170,最高能到 251,SQL 执行时间 50~100ms,写性能比乐观模式提升 10+ 倍。
测试结论
本次模拟的场景就是并发 + 写冲突场景下的乐观和悲观事务模式性能对比,发现悲观事务模式的写入性能比乐观事务模式提升了 10+ 倍的性能,主要问题还是:写冲突高的情况下乐观模式的自动重试策略导致的写入延迟增加,悲观模式通过加锁的方式避免了重试的代价,在一定程度上提升了写入的性能。
注意:在写冲突 + 悲观模式的情况下,建议控制好并发,在一定的并发内提升并发可以继续提升写入,在我的环境下并发超过 50 时会产生大量的锁等待超时 / 死锁报错 (过犹不及)。
参考:
https://docs.pingcap.com/zh/tidb/stable/optimistic-transaction
https://docs.pingcap.com/zh/tidb/stable/pessimistic-transaction
https://pingcap.com/blog-cn/best-practice-optimistic-transaction
https://pingcap.com/blog-cn/pessimistic-transaction-the-new-features-of-tidb
版权声明: 本文为 InfoQ 作者【TiDB 社区干货传送门】的原创文章。
原文链接:【http://xie.infoq.cn/article/710ca0fe90cb9b69301c65ad3】。文章转载请联系作者。
评论