在 MySQL 中模拟 PostgreSQL 的 sequence 功能

介绍

id 生成器介绍 一文中, 我们介绍了几种生成 id 的方式, 其中的 last_insert_id 小节中, 简单说明了中小业务可以使用 last_insert_id 的方式来生成 id, 在 InnoDB 表的情况下容易产生死锁(下文会介绍在一个事务中操作可以降低死锁概率), 将表改成 MyISAM 也可以提高性能, 不过不利于 xtrabackup 的在线备份; 在 postgresql 序列生成器 小节中, 介绍了 postgresql 自带序列生成器的功能, 使用起来也很方便.

如果我们使用的是阿里的 MySQL 分支版本 AliSQL, 则可以直接使用内置的 Sequence 逻辑引擎, 效果类似 PostgreSQL, 不过其基于 MySQL 的 InnoDB 或 MyISAM 引擎, 这样做可以更好的兼容 xtrabackup 等备份.

不过在本文中我们只考虑的普通的 MySQL 版本, 所以我们参考了 emulating-nextval-function-to-get-sequence-in-mysql, 在 MySQL 中实现 PostgreSQL 的三个函数: nextval, setvalcurrval. 当然肯定没有 PostgreSQL 自带的函数全面、强大, 只是实现了通用的功能. 在这个实现中, 我们以 update 替换了 replace 语句. 这样做的好处是更新的时候不需要增加 next-key 锁, 只需要增加 index-record 锁, 减少了死锁发生的概率, 另外函数在事务中执行, 这样可以避免脏读等问题. 下文则详细介绍实现的过程.

PostgrepSQL sequence 操作

在 PostgreSQL 中, 我们需要先创建一个 sequence, 然后才能使用 nextval() 等函数, 如下所示:

cztest_2=> create sequence seq1;
CREATE SEQUENCE
cztest_2=> select setval('seq1', 20); 
 setval 
--------
     20
(1 row)

cztest_2=> select nextval('seq1');    
 nextval 
---------
      21
(1 row)

cztest_2=> select nextval('seq1');
 nextval 
---------
      22
(1 row)

cztest_2=> select currval('seq1');
 currval 
---------
      22
(1 row)

cztest_2=> select nextval('seq1');
 nextval 
---------
      23
(1 row)

在本文中我们只实现 PostgreSQL 的下面几个函数:

nextval(regclass)                 bigint   递增序列并返回新值
currval(regclass)                 bigint   返回最近一次用 nextval 获取的指定序列的数值
setval(regclass, bigint)          bigint   设置序列的当前数值

在 MySQL 中实现

我们是以 MySQL 函数的方式实现了上述的三个功能, 虽然可以通过 select db.func() 的方式查询, 不过还是建议大家最好在每个业务的对应库中进行以下操作, 以免复制规则等引起数据不能同步到 slave.

1. 创建表结构

可以在对应的库中创建表:

CREATE TABLE `sequence_data` (
  `sequence_name` varchar(100) NOT NULL,
  `sequence_increment` int(11) unsigned NOT NULL DEFAULT '1',
  `sequence_min_value` int(11) unsigned NOT NULL DEFAULT '1',
  `sequence_max_value` bigint(20) unsigned NOT NULL DEFAULT '18446744073709551615',
  `sequence_cur_value` bigint(20) unsigned DEFAULT '0',
  `sequence_cycle` tinyint(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`sequence_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

这里的 sequence_name 对应 postgresql 中的 regclass; sequence_increment 则为自增的步长, 默认为 1; sequence_min_valuesequence_max_value 则为序列数的取值范围; sequence_cur_value 为当前序列的值, 也可以当做序列的起始值; sequence_cycle 则类似 postgresql 中的 CYCLE 选项, 序列达到上限后是报错还是重新开始, 默认为 0, 即报错(我们的函数认为 0 不是有效的序列, 实现中返回 NULL).

在该表中, sequence_cur_value 为无符号整形, 并没有考虑负数的情况.

更新数据:

-- This code will create sequence with default values.
INSERT INTO sequence.sequence_data
    (sequence_name)
VALUE  ('sq_my_sequence');
 
-- You can also customize the sequence behavior.
INSERT INTO sequence.sequence_data
    (sequence_name, sequence_increment, sequence_max_value)
VALUE  ('sq_sequence_2', 10, 100);

2. 创建函数

nextval 函数:

创建 nextval 函数, 与上述的参考链接相比, 我们先进行了更新, 再进行查询, 所以上述的表结构 sequence_cur_value 的默认值改为了 0. 另外没有 sequence_name 的话则返回 NULL. 如下所示:

DROP FUNCTION IF EXISTS `nextval`;
DELIMITER $$
 
CREATE FUNCTION `nextval` (`seq_name` varchar(100))
RETURNS bigint(20) NOT DETERMINISTIC
BEGIN
    DECLARE pre_val bigint(20);
    DECLARE cur_val bigint(20);
 
    SELECT sequence_cur_value 
    INTO pre_val
    FROM sequence.sequence_data
    WHERE sequence_name = seq_name;
    
    IF pre_val IS NOT NULL THEN
        UPDATE
            sequence.sequence_data
        SET
            sequence_cur_value = IF (
                (sequence_cur_value + sequence_increment) > sequence_max_value,
                IF (
                    sequence_cycle = TRUE,
                    sequence_min_value,
                    NULL
                ),
                sequence_cur_value + sequence_increment
            )
        WHERE sequence_name = seq_name;
    ELSE
        -- seq_name does not exist
        RETURN NULL;
    END IF;
 
    SELECT sequence_cur_value 
    INTO cur_val
    FROM sequence.sequence_data
    WHERE sequence_name = seq_name;
    
    RETURN cur_val;
END$$

currval 函数:

currval 函数则仅返回当前时刻 sequence_cur_value 的值, 如果当前值为 0 的话则返回 NULL.

DROP FUNCTION IF EXISTS `currval`;
DELIMITER $$
 
CREATE FUNCTION `currval` (`seq_name` varchar(100))
RETURNS bigint(20) READS SQL DATA
BEGIN
    DECLARE cur_val bigint(20);
 
    SELECT sequence_cur_value 
    INTO cur_val
    FROM sequence.sequence_data
    WHERE sequence_name = seq_name;
 
    IF cur_val = 0 THEN
        RETURN NULL;
    END IF;
    RETURN cur_val;
END$$

setval 函数

setval 函数则更新 sequence_cur_value 的值, 如果要设置起始值可以通过该函数完成. 另外我们并没有实现 PostgreSQL 中setval 函数的下面的语法, cycle 选项可以在往 sequence_data 表插入数据的时候就指定好:

setval(regclass, bigint, boolean)

我们去掉了 cycle 选项:

DROP FUNCTION IF EXISTS `setval`;
DELIMITER $$
 
CREATE FUNCTION `setval` (`seq_name` varchar(100), `seq_val` bigint(20))
RETURNS bigint(20) NOT DETERMINISTIC
BEGIN
    DECLARE pre_val bigint(20);
    DECLARE cur_val bigint(20);

    -- return null if val less than 0
    IF seq_val + 0 < 0 THEN
        RETURN NULL;
    END IF;

    SELECT sequence_cur_value 
    INTO pre_val 
    FROM sequence.sequence_data
    WHERE sequence_name = seq_name;  

    IF pre_val IS NOT NULL THEN
        UPDATE sequence.sequence_data SET
        sequence_cur_value = IF (
            (sequence_cur_value + seq_val) > sequence_max_value,
            IF (
                sequence_cycle = TRUE,
                sequence_min_value,
                NULL
            ),
            seq_val
        )
        WHERE sequence_name = seq_name;
    ELSE
        -- seq_name does not exist.
        RETURN NULL;
    END IF;

    SELECT sequence_cur_value 
    INTO cur_val 
    FROM sequence.sequence_data
    WHERE sequence_name = seq_name;  
 
    RETURN cur_val;
END$$

如何使用

可以同上述的 PostgreSQL 进行比较:

mysql root@[localhost:s3301 sequence] > select setval('sq_my_sequence', 20) as setval; 
+--------+
| setval |
+--------+
|     20 |
+--------+
1 row in set (0.01 sec)

mysql root@[localhost:s3301 sequence] > select nextval('sq_my_sequence') as nextval;            
+---------+
| nextval |
+---------+
|      21 |
+---------+
1 row in set (0.01 sec)

mysql root@[localhost:s3301 sequence] > select nextval('sq_my_sequence') as nextval;
+---------+
| nextval |
+---------+
|      22 |
+---------+
1 row in set (0.00 sec)

mysql root@[localhost:s3301 sequence] > select currval('sq_my_sequence') as currval;     
+---------+
| currval |
+---------+
|      22 |
+---------+
1 row in set (0.00 sec)

mysql root@[localhost:s3301 sequence] > select nextval('sq_my_sequence') as nextval;
+---------+
| nextval |
+---------+
|      23 |
+---------+
1 row in set (0.01 sec)

另外这些函数也支持事务, 在默认隔离级别为 REPEATABLE-READ 下操作:

mysql root@[localhost:s3301 sequence] > begin;
Query OK, 0 rows affected (0.00 sec)

mysql root@[localhost:s3301 sequence] > select nextval('sq_my_sequence');
+---------------------------+
| nextval('sq_my_sequence') |
+---------------------------+
|                        27 |
+---------------------------+
1 row in set (0.00 sec)

mysql root@[localhost:s3301 sequence] > rollback;
Query OK, 0 rows affected (0.00 sec)

mysql root@[localhost:s3301 sequence] > select currval('sq_my_sequence');
+---------------------------+
| currval('sq_my_sequence') |
+---------------------------+
|                        26 |
+---------------------------+
1 row in set (0.00 sec)

权限及复制

执行权限

上面的 nextval 和 setval 函数都有更新和查询操作, 如果函数创建的时候 definer 是 root 用户, 则只需要给业务用户赋予这些函数的 execute 权限即可, 更新和查询会以 definer 用户的权限操作; 如果 definer 是普通用户, 那么该用户至少要有 select, update 权限, 业务用户则需要 execute 权限;

复制

在主从环境中, 可以参考官方文档 stored-programs-logging 查看存储过程及函数对 binlog 的影响. 在上述的 setval 和 nextval 函数中, 由于函数更新了数据, 每次的返回值也不同, 所以我们声明了 NOT DETERMINISTIC, 这些声明需要开启参数 log_bin_trust_function_creators , 另外其中并没有使用一些不安全函数, 所以在复制格式为 statementmixed 的时候, binlog 都以正常的语句显示, 如下:

#171207 11:28:49 server id 396517  end_log_pos 974 CRC32 0x1e5076e1     Query   thread_id=8818  exec_time=0     error_code=0
SET TIMESTAMP=1512530929/*!*/;
BEGIN
/*!*/;
# at 974
#171207 11:28:49 server id 396517  end_log_pos 1132 CRC32 0x2a662df9    Query   thread_id=8818  exec_time=0     error_code=0
SET TIMESTAMP=1512530929/*!*/;
SELECT `sequence`.`nextval`(_utf8'sq_my_sequence' COLLATE 'utf8_general_ci')
/*!*/;
# at 1132
#171207 11:28:49 server id 396517  end_log_pos 1163 CRC32 0xdefdf987    Xid = 386828
COMMIT/*!*/;

可以看到整个执行过程是在一个事务中完成. 如果函数中存在不安全函数, 或者为了一致性以及数据恢复方面的考虑可以选用 ROW 格式:

# at 581
#171206 11:45:57 server id 396517  end_log_pos 657 CRC32 0x8aa02039     Query   thread_id=8928  exec_time=0     error_code=0
SET TIMESTAMP=1512531957/*!*/;
BEGIN
.....
### UPDATE `sequence`.`sequence_data`
### WHERE
###   @1='sq_my_sequence'
###   @2=1
###   @3=1
###   @4=-1 (18446744073709551615)
###   @5=51022
###   @6=0
### SET
###   @1='sq_my_sequence'
###   @2=1
###   @3=1
###   @4=-1 (18446744073709551615)
###   @5=51023
###   @6=0
# at 844
#171206 11:45:57 server id 396517  end_log_pos 875 CRC32 0x04131f6e     Xid = 388635
COMMIT/*!*/;

mysqlslap 压测

nextval 函数方式压测

直接使用 mysqlslap 进行压测, 使用 10 个线程执行 5w 条查询:

# mysqlslap -P 3301 -u root \
--number-of-queries 50000 -c 10 
--create-schema sequence 
--query "select sequence.nextval('sq_my_sequence')"                                                                                                       
Benchmark
        Average number of seconds to run all queries: 22.707 seconds
        Minimum number of seconds to run all queries: 22.707 seconds
        Maximum number of seconds to run all queries: 22.707 seconds
        Number of clients running queries: 10
        Average number of queries per client: 5000

执行后查看 currval 信息:


mysql root@[localhost:s3301 sequence] > select currval('sq_my_sequence') as currval;
+---------+
| currval |
+---------+
|   50023 |
+---------+
1 row in set (0.00 sec)

平均每秒执行2200多, 在执行的过程查看 innodb status 信息, 可以看到只有 index-record 锁占用, 没有 gap 锁:

------- TRX HAS BEEN WAITING 0 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 327 page no 3 n bits 72 index `PRIMARY` of table `sequence`.`sequence_data` trx id 1960370 lock_mode X locks rec but not gap waiting

last_insert_id 方式压测

同样的我们使用 id 生成器介绍 中的 last_insert_id 方式进行测试.

InnoDB 表测试

我们分两种情况测试, 第一种 replace 和 select 不在一个事务中, 第二种在一个事务中:

replace 和 select 分开

guid 为 InnoDB 引擎的情况下很快就出现死锁, 事务级别为默认的 REPEATABLE-READ:

mysqlslap -P 3301 -u root \
--number-of-queries 50000 -c 10 \
--create-schema sequence \
--query "replace into guid (stub) values ('a'); select last_insert_id()" 

mysqlslap: Cannot run query replace into guid (stub) values ('a'); select last_insert_id() ERROR : Deadlock found when trying to get lock; try restarting transaction
mysqlslap: Cannot run query replace into guid (stub) values ('a'); select last_insert_id() ERROR : Deadlock found when trying to get lock; try restarting transaction
mysqlslap: Cannot run query replace into guid (stub) values ('a'); select last_insert_id() ERROR : Deadlock found when trying to get lock; try restarting transaction

innodb status 的死锁信息提示的则比较明显, 存在 gap 锁:

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 328 page no 4 n bits 72 index `stub` of table `sequence`.`guid` trx id 2170941 lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 328 page no 4 n bits 72 index `stub` of table `sequence`.`guid` trx id 2170941 lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (1)

表结构如下, 从 id 值来看插入的记录不多:

mysql root@[localhost:s3301 sequence] > show create table guid\G   
*************************** 1. row ***************************
       Table: guid
Create Table: CREATE TABLE `guid` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `stub` char(1) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  UNIQUE KEY `stub` (`stub`)
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

mysql root@[localhost:s3301 sequence] > select * from guid;
+----+------+
| id | stub |
+----+------+
| 11 | a    |
+----+------+
1 row in set (0.00 sec)

replace 和 select 在一个事务中

放到一个事务中, 使用默认隔离级别 REPEATABLE-READ 则相对正常, 平均每秒达到了 4400 多:

mysqlslap -P 3301 -u root \
--number-of-queries 50000 -c 10 \
--create-schema sequence \
--query "begin; replace into guid (stub) values ('a'); select last_insert_id(); commit" 

Benchmark
        Average number of seconds to run all queries: 11.353 seconds
        Minimum number of seconds to run all queries: 11.353 seconds
        Maximum number of seconds to run all queries: 11.353 seconds
        Number of clients running queries: 10
        Average number of queries per client: 5000

innodb status 死锁的概率比上面的低了很多, 在我们持续压测中, 死锁的现象依旧出现, 同样存在 gap 锁, 不过吞吐量还是蛮高的:

*** (2) TRANSACTION:
TRANSACTION 4538744, ACTIVE 0 sec updating or deleting
mysql tables in use 1, locked 1
11 lock struct(s), heap size 1184, 6 row lock(s), undo log entries 2
MySQL thread id 17826, OS thread handle 0x7ff2d3c87700, query id 6969996 localhost root update
replace into guid (stub) values ('a')
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 328 page no 4 n bits 88 index `stub` of table `sequence`.`guid` trx id 4538744 lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 328 page no 4 n bits 88 index `stub` of table `sequence`.`guid` trx id 4538744 lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (1)

MyISAM 表测试

在 guid 表为 MyISAM 引擎的情况下, 因为每次操作都是表锁, 所以死锁的概率很难发生, 性能方面也会较好, 平均每秒达到了 3200 多:

mysqlslap -u root \
--number-of-queries 50000 -c 10 \
--create-schema sequence \
--query "replace into guid2 (stub) values ('a'); select last_insert_id()" 

Benchmark
        Average number of seconds to run all queries: 15.397 seconds
        Minimum number of seconds to run all queries: 15.397 seconds
        Maximum number of seconds to run all queries: 15.397 seconds
        Number of clients running queries: 10
        Average number of queries per client: 5000

表结构如下, 5w 条都正常插入:

mysql root@[localhost:s3301 sequence] > show create table guid2\G
*************************** 1. row ***************************
       Table: guid2
Create Table: CREATE TABLE `guid2` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `stub` char(1) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  UNIQUE KEY `stub` (`stub`)
) ENGINE=MyISAM AUTO_INCREMENT=50001 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

mysql root@[localhost:s3301 sequence] > select * from guid2;
+-------+------+
| id    | stub |
+-------+------+
| 50000 | a    |
+-------+------+
1 row in set (0.01 sec)

总结

从上述的结果来看, InnoDB 表的情况下, replace 和 select 放到一个事务中的性能是最好的, MyISAM 则次之, nextval 函数性能则较差. 虽然replaceselect 放到一个事务中性能最高, 不过还是有死锁出现的情况. 不过很多开发者对于此问题并没有使用事务, 所以从性能和稳定性的角度看建议大家选择 MyISAM 表, 这样也就不会有上面提到的权限和复制问题, 程序逻辑也不会有多大的变化. 当然如果习惯了 PostgreSQL 的 sequence 方式并且想生成的序列也支持事务, 就可以使用本文的几个函数, 不过性能会有所下降.