MySQL5.7主键id自增,断电重启后,缓存的id将丢失带来的灾难

上周三凌晨两点,老张被电话炸醒——线上系统数据乱了。订单执行表里出现了id重复的记录,关联查询全部跑偏,历史数据跟活跃数据串了。查了三个小时,最终定位到的原因竟然是一次断电重启。

说白了,就是MySQL 5.7自增id在断电后"失忆"了。

事情是怎么发生的

先说场景。系统里有两张表:

  • 执行表A:存当前正在执行的记录,id自增
  • 历史表B:和A表结构完全相同,但做了分表处理

业务逻辑不复杂:A表的数据满足一定条件后,搬进B表,然后从A表删除。典型的"活跃-归档"模式,很多业务系统都这么干。

问题出在一次意外的断电重启。

重启之后,A表新插入的数据,id居然用到了之前已经删除过的值。而这些id对应的数据,早就搬进B表了。结果就是——A表和B表出现了相同的id,但指向的是完全不同的业务数据。所有基于id的关联、去重、对比逻辑,全废了。

为什么会"失忆"

这是MySQL 5.7 InnoDB引擎的一个设计特点,严格来说不算bug,但确实是个坑。

在MySQL 5.7中,InnoDB的自增计数器(auto_increment counter)是存在内存里的,而不是持久化到磁盘。数据库正常运行时,计数器递增没问题。但一旦非正常关闭(断电、kill -9、崩溃),内存里的计数器就丢了。

重启之后,InnoDB会这么算新的自增值:

新自增值 = SELECT MAX(id) FROM 表

看着没问题?但如果之前有删除操作,MAX(id)就会比内存里记的值小。于是新插入的数据,就会用到已经"释放"的id。

举个具体例子:

  1. A表插入了id=1到100的记录
  2. id=1到80的数据搬进B表,A表删除这80条
  3. 此时A表里id=81到100,内存里自增计数器是101
  4. 断电了
  5. 重启后,InnoDB算MAX(id)=100,新自增值变成101

这个例子还好,id没有真正冲突。但如果删除得更彻底:

  1. A表插入了id=1到100
  2. 全部搬到B表,A表清空
  3. 内存里自增计数器是101
  4. 断电重启
  5. InnoDB算MAX(id)=NULL,新自增值从1开始

这下id=1到100就重复了。A表的新数据和B表的旧数据撞id了。

复现方案

光说不练假把式,下面给出完整的复现步骤,可以在本地MySQL 5.7环境验证。

环境准备

-- 创建执行表A
CREATE TABLE table_a (
    id INT AUTO_INCREMENT PRIMARY KEY,
    content VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

-- 创建历史表B
CREATE TABLE table_b (
    id INT PRIMARY KEY,
    content VARCHAR(100),
    created_at TIMESTAMP
) ENGINE=InnoDB;

正常业务流程

-- 步骤1:向A表插入数据
INSERT INTO table_a (content) VALUES ('task_1'), ('task_2'), ('task_3'), 
('task_4'), ('task_5');

-- 步骤2:满足条件,搬迁到B表并删除A表数据
INSERT INTO table_b SELECT * FROM table_a WHERE id <= 3;
DELETE FROM table_a WHERE id <= 3;

-- 此时A表有id=4,5,自增计数器=6
SELECT * FROM table_a;
-- +----+---------+---------------------+
-- | id | content | created_at          |
-- +----+---------+---------------------+
-- |  4 | task_4  | 2025-01-01 10:00:00 |
-- |  5 | task_5  | 2025-01-01 10:00:01 |
-- +----+---------+---------------------+

模拟断电重启

# 模拟断电:直接杀掉MySQL进程(不要用正常关闭)
kill -9 $(pidof mysqld)

# 重启MySQL
systemctl start mysqld

验证id重复

-- 重启后插入新数据
INSERT INTO table_a (content) VALUES ('task_6');

-- 查看新数据的id
SELECT * FROM table_a;
-- 预期结果:新数据的id可能是1、2或3(已被B表使用过的id)
-- 而不是期望的6

-- 对比B表
SELECT * FROM table_b;
-- B表中已有id=1,2,3的记录

-- 冲突!A表和B表出现了相同id

如果A表被清空后断电,重启后新数据会从id=1开始,和B表完全重叠。这就是灾难。

也可以用更快捷的方式验证,不用真的断电:

-- 插入数据后删除
INSERT INTO table_a (content) VALUES ('test');
DELETE FROM table_a;

-- 正常重启(这个场景下也会重现)
-- 或者直接kill -9后重启

解决方案

知道了原因,解决起来思路就清晰了。分短期止血和长期根治。

方案一:升级到MySQL 8.0(根本解决)

MySQL 8.0把自增计数器的值持久化到了redo log里,重启后不会丢失。这是最彻底的方案。

-- MySQL 8.0中,自增计数器的变化会写入redo log
-- 崩溃恢复时会从redo log中恢复最新值
-- 不再依赖SELECT MAX(id)重新计算

但升级数据库不是闹着玩的,需要充分测试兼容性,短期不一定能落地。

方案二:在业务层维护id分配

不依赖数据库的自增,用Redis或独立序列服务来生成id。

import redis

r = redis.Redis(host='localhost', port=6379, db=0)

def get_next_id():
    return r.incr('table_a_id_seq')

# 插入时指定id
next_id = get_next_id()
cursor.execute(
    "INSERT INTO table_a (id, content) VALUES (%s, %s)", 
    (next_id, 'task_x')
)

Redis的incr是原子操作,数据也持久化在磁盘上(配置AOF或RDB),重启不会丢。但引入了新的依赖,架构变复杂了。

方案三:修改归档逻辑,保留最大id(最实用的短期方案)

核心思路:不要删除A表的最新记录,让MAX(id)始终大于已归档的最大id。

-- 归档时,不要删除A表中最大的那条记录
-- 或者:删除后立刻插入一条占位记录
INSERT INTO table_b SELECT * FROM table_a WHERE id <= 80;
DELETE FROM table_a WHERE id <= 80;

-- 保留一条哨兵记录,确保MAX(id)不会回退
-- 如果A表必须清空,插入一条占位记录:
INSERT INTO table_a (content) VALUES ('__placeholder__');

简单粗暴但有效。只要A表里始终有一条id大于已归档记录的行,重启后MAX(id)就不会回退。

占位记录可以在下次真实插入前删除,不影响业务。

方案四:使用自增步长+起始偏移

给A表和B表设置不同的自增起始值和步长,即使id回退也不会和已归档数据重叠。

-- A表:id从1开始,步长2(奇数)
ALTER TABLE table_a AUTO_INCREMENT=1;
SET @@auto_increment_increment=2;
SET @@auto_increment_offset=1;

-- B表:id从2开始,步长2(偶数)
-- 或使用独立的id序列

这个方案的问题是步长是全局设置,影响所有表。生产环境一般不这么搞。更适合的场景是双主互备,而不是这里的问题。

方案五:归档时不删除,用状态字段标记

彻底避免删除操作,自增值就永远不会回退。

-- A表增加状态字段
ALTER TABLE table_a ADD COLUMN status TINYINT DEFAULT 1 COMMENT '1-执行中 2-已归档';

-- 归档操作改为更新状态
INSERT INTO table_b SELECT * FROM table_a WHERE status = 1 AND 满足条件;
UPDATE table_a SET status = 2 WHERE 满足条件;

-- 查询时过滤状态
SELECT * FROM table_a WHERE status = 1;

数据不删除,MAX(id)永远是最大的,重启后自增不会回退。代价是A表会越来越大,需要定期清理很老的已归档记录。但至少id不会重复。

推荐的组合打法

实际项目中,建议这样组合:

阶段 方案 理由
短期止血 方案三(哨兵记录) 零侵入,改动最小
中期优化 方案五(状态字段) 根除删除导致的问题
长期根治 方案一(升级8.0) 从引擎层面解决

还要注意的

这个问题不仅影响A表B表这种归档场景。任何有大量DELETE操作的表,在MySQL 5.7下断电重启后都可能碰到自增id回退。比如:

  • 临时表清空后重启
  • 定期清理过期数据的表
  • 使用DELETE而非TRUNCATE清表的场景

如果你还在用MySQL 5.7,赶紧检查一下线上有没有类似的归档+删除逻辑。断电这种事,不是会不会发生的问题,是什么时候发生的问题。

别等到凌晨两点被电话叫醒才想起来查。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容