故障档案:序列号消耗过快导致整型溢出

如果您在表上用了Interger的序列号,最好还是考虑一下可能溢出的情况。

0x01 概览

  • 故障表现:

    • 某张使用自增列的表序列号涨至整型上限,无法写入。
    • 发现表中的自增列存在大量空洞,很多序列号没有对应记录就被消耗掉了。
  • 故障影响:非核心业务某表,10分钟左右无法写入。

  • 故障原因:

    • 内因:使用了INTEGER而不是BIGINT作为主键类型。
    • 外因:业务方不了解SEQUENCE的特性,执行大量违背约束的无效插入,浪费了大量序列号。
  • 修复方案:

    • 紧急操作:降级线上插入函数为直接返回,避免错误扩大。
    • 应急方案:创建临时表,生成5000万个浪费空洞中的临时ID,修改插入函数,变为先检查再插入,并从该临时ID表中取ID。
    • 解决方案:执行模式迁移,将所有相关表的主键与外键类型更新为Bigint。

原因分析

内因:类型使用不当

业务使用32位整型作为主键自增ID,而不是Bigint。

  • 除非有特殊的理由,主键,自增列都应当使用BIGINT类型。

外因:不了解Sequence的特性

  • 非要使用如果会频繁出现无效插入,或频繁使用UPSERT,需要关注Sequence的消耗问题。
  • 可以考虑使用自定义发号函数(类Snowflake)

在PostgreSQL中,Sequence是一个比较特殊的类型。特别是,在事务中消耗的序列号不会回滚。因为序列号能被并发地获取,不存在逻辑上合理的回滚操作。

在生产中,我们就遇到了这样一种故障。有一张表直接使用了Serial作为主键:

CREATE TABLE sample(
	id   	SERIAL PRIMARY KEY,
	name  	TEXT UNIQUE,
    value   INTEGER
);

而插入的时候是这样的:

INSERT INTO sample(name, value) VALUES(?,?)

当然,实际上由于name列上的约束,如果插入了重复的name字段,事务就会报错中止并回滚。然而序列号已经被消耗掉了,即使事务回滚了,序列号也不会回滚。

vonng=# INSERT INTO sample(name, value) VALUES('Alice',1);
INSERT 0 1
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       1
(1 row)

vonng=# INSERT INTO sample(name, value) VALUES('Alice',1);
ERROR:  duplicate key value violates unique constraint "sample_name_key"
DETAIL:  Key (name)=(Alice) already exists.
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       2
(1 row)

vonng=# BEGIN;
BEGIN
vonng=# INSERT INTO sample(name, value) VALUES('Alice',1);
ERROR:  duplicate key value violates unique constraint "sample_name_key"
DETAIL:  Key (name)=(Alice) already exists.
vonng=# ROLLBACK;
ROLLBACK
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       3

因此,当执行的插入有大量重复,即有大量的冲突时,可能会导致序列号消耗的非常快。出现大量空洞!

另一个需要注意的点在于,UPSERT操作也会消耗序列号!从表现上来看,这就意味着即使实际操作是UPDATE而不是INSERT,也会消耗一个序列号。

vonng=# INSERT INTO sample(name, value) VALUES('Alice',3) ON CONFLICT(name) DO UPDATE SET value = EXCLUDED.value;
INSERT 0 1
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       4
(1 row)

vonng=# INSERT INTO sample(name, value) VALUES('Alice',4) ON CONFLICT(name) DO UPDATE SET value = EXCLUDED.value;
INSERT 0 1
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       5
(1 row)

解决方案

线上所有查询与插入都使用存储过程。非核心业务,允许接受短暂的写入失效。首先降级插入函数,避免错误影响AppServer。因为该表存在大量依赖,无法直接修改其类型,需要一个临时解决方案。

检查发现ID列中存在大量空洞,每10000个序列号中实际只有1%被使用。因此使用下列函数生成临时ID表。

CREATE TABLE sample_temp_id(id INTEGER PRIMARY KEY);

-- 插入约5000w个临时ID,够用十几天了。
INSERT INTO sample_temp_id
    SELECTT generate_series(2000000000,2100000000) as id EXCEPT SELECT id FROM sample;

-- 修改插入的存储过程,从临时表中Pop出ID。
DELETE FROM sample_temp_id WHERE id = (SELECT id FROM sample_temp_id FOR UPDATE LIMIT 1) RETURNING id;

修改插入存储过程,每次从临时ID表中取一个ID,显式插入表中。


经验与教训

能用 BIGINT 的就别用 INT,另外 UPSERT 的时候需要特别注意。

Last modified 2024-05-22: adjust blog structure (99d80185)