背景
在治理大表过程中发现一个有趣现象,某张表自增主键 id 最大值超过 3亿,但表里实际数据行数只有不到500w,查看表数据时发现 id 值大量不连续且相邻两个 id 之间差值非常大,为啥会出现这种情况?这篇文章对整个排查过程以及根本原因做了一个总结。
表结构(简化了一些字段)
sql
CREATE TABLE `app_visit_stat` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`app_id` int(11) unsigned NOT NULL COMMENT '应用id',
`user_id` bigint(20) unsigned NOT NULL COMMENT '用户id',
`visit_num` int(11) unsigned NOT NULL COMMENT '访问次数',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_user_id_app_id` (`user_id`,`app_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='应用访问统计表'
表结构比较简单,用途为用户的应用访问次数统计。
旧代码逻辑
排查代码,发现增加应用访问次数全局只有一条 sql 语句:
sql
INSERT INTO app_visit_stat( app_id, user_id, visit_num) VALUES (#{appId}, #{userId}, 1)
ON DUPLICATE KEY UPDATE visit_num = visit_num + 1
这条 sql 是 mysql 特有写法,含义为存在主键或唯一键冲突时执行更新操作,否则执行插入操作。从语意看,sql 没啥问题,难道是因为执行更新操作时把自增id也更新掉了?我们验证一下看看:
go
# 验证相关sql语句
SELECT * FROM `app_visit_stat` ORDER BY id DESC;
# 这条语句多执行几次
INSERT INTO `app_visit_stat` (`user_id`, `app_id`, `visit_num`) VALUES (1, 1, 1)
ON DUPLICATE KEY UPDATE `visit_num` = `visit_num` + 1;
# 插入一条新的记录INSERT INTO `app_visit_stat` (`user_id`, `app_id`, `visit_num`) VALUES (1, 2, 1)
ON DUPLICATE KEY UPDATE `visit_num` = `visit_num` + 1;
执行结果(微信gif图有帧数限制,这里就不传了):
通过实验发现,执行多次 update 操作后,再执行一次 insert,新插入记录的 id 就不连续,且规则为当前最大 id + 中间变更次数。相当于使用 insert into .. on duplicate key update .. 时即使执行了 update 操作,也将 id 隐式+1。为什么执行 update 操作,id 也会自增?想要知道答案,我们需要了解一下 mysql 自增id的实现方式。
自增计数器
当一张表包含自增字段时,mysql 会为其创建一个计数器,用于为自增字段分配值。计数器的值并不保存在表结构信息中,保存位置不同版本略有区别。
8.0 版本之前
To initialize an auto-increment counter after a server restart, InnoDB executes the equivalent of the following statement on the first insert into a table containing an AUTO_INCREMENT column.
计数器值存储在内存中,mysql 启动时,通过下面这条 sql 从表中读取最大值并初始化计数器:
sql
SELECT MAX(ai_col) FROM table_name FOR UPDATE;
计数器默认步长为1,但可以通过配置项 auto_increment_increment 调整。如果是空表,计数器默认值初始化为1,但可以通过 auto_increment_offset 配置。
8.0 版本之后
In MySQL 8.0, this behavior is changed. The current maximum auto-increment counter value is written to the redo log each time it changes and saved to the data dictionary on each checkpoint. These changes make the current maximum auto-increment counter value persistent across server restarts.
8.0之后,计数器值存储在 .cfg (数据词典)文件中,计数器每次改变时都会将值写入 redo log 里,并且每次 checkpoint 时将值保存到数据词典中,重启时通过数据词典恢复。auto_increment_increment 、auto_increment_offset 配置项仍然有效。
插入语句
所有可以在表中新增记录的语句,包含 insert、insert...select、replace、replace...select、load data,这些插入语句又可以被归纳为三类:
Simple inserts
简单插入,插入的数据行数是固定的
Bulk inserts 批量插入,插入的数据行数不固定Mixed-mode inserts
混合插入,属于简单插入的一种,但是其中某些行指定了自增字段值。如下面这个sql:
sql
INSERT INTO user (id, name) VALUES (1,'张三'), (NULL,'李四'), (5,'王五'), (NULL,'牛二');
另外,insert into ... on duplicate key update ... 也归属到该分类。
mysql 通过自增计数器来给自增字段赋值,当存在大量并发插入时,为了保证自增字段值的正确性,innodb 引入 auto-inc 锁来处理竞争。该锁有三种模式,可以通过 innodb_autoinc_lock_mode 配置项进行调整。
innodb_autoinc_lock_mode = 0 (普通模式)
在这种模式下,所有的插入语句(包含自增字段)都会使用表级别的 auto-inc 锁,该锁在语句执行结束后释放(不是事务结束) 。举个例子:
sql
Tx1:
SELECT ...INSERT INTO user (name) SELECT 1000 rows from another table ... # Bulk inserts
SELECT ...
Tx2:
INSERT INTO user (name) VALUES ('赵四'); # Simple inserts
SELECT ...
事务Tx1的第2条语句执行结束后,Tx2的第一条语句才可以拿到 AUTO-INC 锁
这样做的好处是可以保障 Tx1 的插入语句不管插入了多少行,自增字段 id 不会乱序,Tx2 的插入语句得到的自增 id 一定是小于或者大于 Tx1 的自增 id。这种模式可以在 binlog_format = statement 模式下保障数据恢复、数据同步时不会出现脏数据(主要是自增id),因为其通过表级锁规避了这种情况。缺点就是性能较差。
innodb_autoinc_lock_mode =1 (连续模式)
默认模式,在 "Bulk inserts" 场景下,与普通模式类似,会使用一个特殊的表级别 auto-inc 锁来保障 id 不会串。在 "Simple inserts" 场景下,会通过一个互斥锁(轻量级)来获所需数量的自动自增值来避免表级别锁,提升插入性能。这一加锁时机为获取值时,而不是语句结束。
在以上两种场景下,该模式可以保证获取自增 id 的连续性和在 binlog_format = statement 模式下同步复制的正确性。但有一个例外,在 "Mixed-mode insters " 场景下,InnoDB 分配的自增值要比插入的行数要多,但能保证值都是连续生成的,且高于最近执行的前一个插入语句生成的自动增量值,"多余"的 id 会丢失。这个就解释了 insert into ... on duplicate key update ... 为什么会导致自增 id 不连续。
innodb_autoinc_lock_mode = 2 (交叉模式)
在这个模式下,所有的插入语句都不会使用表级锁。因为存在多个语句同时执行的情况,该模式可以保证所有并发执行的 insert 语句获取到的自增值是单调递增的,但可能不连续。这是最快的锁模式,但是在 binlog_format = statement 模式下恢复数据或复制时,无法保证数据一致(自增id可能会串),但可用于 binglog_format = row or binglog_format = mixed 模式。
注意:不管基于何种模式,自增字段值都有可能不连续,如事务回滚,事务中产生的自增字段值就会被丢弃。****
优化手段
找到根本原因后,就可以优化业务逻辑,主要方式有两种:
-
修改 innodb_autoinc_lock_mode 配置,将其值改为 0 (普通模式),该模式下,所有插入语句都会获取表级别 auto-inc 锁,并在语句执行结束后释放锁。在整个加锁过程中根据需要拿自增值,因此当 insert into ... on duplicate key update ... 执行更新操作时,自增计数器并不会分配值。但该模式可能会影响写入性能,需要综合评估,不是特别推荐
-
修改业务逻辑,将 insert into ... on duplicate key update ... 语句修改为 select + insert or update 两条语句,注意处理一下并发场景(用分布式锁或db乐观锁)
总结
作为业务研发,在做技术设计时,需要熟练掌握自己所选方案的优劣,结合稳定性、扩展性、成本等综合考量并做取舍,切不可一知半解胡乱应用,给自己埋坑。拿这个 case 来讲,如果自增主键 id 被设计成 int 类型,且该业务数据更新非常频繁,可能一年半载 id 最大值就会超出 int 类型上限,如果没有有效监控手段会直接导致线上故障,恢复时间也会比较长(大表字段类型变更本身是复制耗时很长,期间 cpu 可能会被打满对正常其他 sql 产生影响 ---- 现在有其他方式降级稍微降低影响但依赖 dba 操作,如果这个字段被其他业务依赖,那整个链路变更成本更大)。
关注微信公众号可第一时间看到最新文章哟,公众号原文链接:insert into...on duplicate key update 引发自增主键不连续排查总结 (qq.com)