insert into...on duplicate key update 引发自增主键不连续排查总结

背景

在治理大表过程中发现一个有趣现象,某张表自增主键 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_incrementauto_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)

相关推荐
知初~4 小时前
出行项目案例
hive·hadoop·redis·sql·mysql·spark·database
子非衣6 小时前
MySQL修改JSON格式数据示例
android·mysql·json
钊兵7 小时前
数据库驱动免费下载(Oracle、Mysql、达梦、Postgresql)
数据库·mysql·postgresql·oracle·达梦·驱动
隔壁老王1569 小时前
mysql实时同步到es
数据库·mysql·elasticsearch
Hanson Huang11 小时前
【存储中间件API】MySQL、Redis、MongoDB、ES常见api操作及性能比较
redis·mysql·mongodb·es
LUCIAZZZ12 小时前
EasyExcel快速入门
java·数据库·后端·mysql·spring·spring cloud·easyexcel
yuanbenshidiaos12 小时前
【正则表达式】
数据库·mysql·正则表达式
雾里看山14 小时前
【MySQL】内置函数
android·数据库·mysql
geovindu14 小时前
python: SQLAlchemy (ORM) Simple example using mysql in Ubuntu 24.04
python·mysql·ubuntu