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)

相关推荐
星辰离彬5 小时前
Java 与 MySQL 性能优化:Java应用中MySQL慢SQL诊断与优化实战
java·后端·sql·mysql·性能优化
程序猿小D7 小时前
[附源码+数据库+毕业论文]基于Spring+MyBatis+MySQL+Maven+jsp实现的个人财务管理系统,推荐!
java·数据库·mysql·spring·毕业论文·ssm框架·个人财务管理系统
发仔12312 小时前
Oracle与MySQL核心差异对比
mysql·oracle
叁沐14 小时前
MySQL 08 详解read view:事务到底是隔离的还是不隔离的?
mysql
wkj00115 小时前
navicate如何设置数据库引擎
数据库·mysql
ladymorgana15 小时前
【Spring Boot】HikariCP 连接池 YAML 配置详解
spring boot·后端·mysql·连接池·hikaricp
kk在加油18 小时前
Mysql锁机制与优化实践以及MVCC底层原理剖析
数据库·sql·mysql
合作小小程序员小小店18 小时前
web网页开发,在线%ctf管理%系统,基于html,css,webform,asp.net mvc, sqlserver, mysql
mysql·sqlserver·性能优化·asp.net·mvc
JosieBook18 小时前
【Java编程动手学】Java常用工具类
java·python·mysql
hello 早上好18 小时前
MsSql 其他(2)
数据库·mysql