id 生成器介绍

By | 2017/03/30

背景介绍

在一般的业务场景中, 初始的时候简单的自增数(比如MySQL 自增键)就可以很好的满足需求, 不过随着业务的发展和驱动, 尤其是在分布式的场景中, 如何生成全局的唯一 id 便成了需要慎重考虑的事情. 业务之间如何协调, 生成的序列是否还有其它需求等都需要重新设计, 下文则介绍生成唯一 id 的不同方式以及各自适用的场景.

1. twitter Snowflake 介绍

原文见: announcing-snowflake

twitter 碰到的问题

twitter 使用 MySQL 存储线上的数据, 不过随着业务的发展, 现在已经成为了很大的数据库集群. 由于种种原因, 在一些细节方面, twitter 使用分布式数据库 Cassandra 或水平拆分 MySQL 来更好的服务全局的博文及帖子.

Cassandra 并没有内置类似 MySQL 自增主键的功能, 这也意味着随着业务的扩张, 使用 Cassandra 很难在序列 id 方面提供一个通用的解决方案(one-size-fits-all solution), 这个问题在水平拆分 MySQL 的架构中也同样存在.

基于这些问题, twitter 提出了以下需求:

1. 每秒生成上万的 id 号, 并且能以高可用方式提供服务;
2. 由于业务的关系只能选择非协调(业务无关)的方式生成 id 号;
3. id 号大致上要能排序, 这意味着同时发表 A 和 B 两篇文章, 他们的 id 号应该是相近的.
4. id 号应该是 64 位大小.

可选的解决方案

twitter 也考虑了几种方式来满足上述的需求:

1. 基于 MySQL 的服务;
2. UUID 方式;
3. zookeeper sequential nodes;

基于 MySQL-based ticket servers 本质上通过自增 id 来实现, 不过这种方式在程序不重构的情况下很难保证 id 号按顺序生成, 也不能按照时间排序; 而 UUID 则是 128 位的, 也有概率发生冲突, 同样也没有时间戳; 而 zookeeper 的时序节点则难以满足上万每秒的性能.

twitter 的解决方案

为了生成能够大致上可以排序的 64 位 id 号, twitter 提出以三个字段组合生成 id 号: 时间戳(timestamp), worker(工作号), 序列数(sequence number).

序列数和工作号是在每个线程连接 zookeeper 后就确定的, 详细的代码见: snowflake

这种方式有几点好处, 首先, 开始部分都是时间戳, 可以很方便的建立索引; 其次, 同一个线程下发表的文章或帖子可以进行排序, 而且 id 号临近; 另外, 整体上看 id 号是近似排序的.

id 号实现

twitter 的 id 号以如下部分组合实现, 构成63位的整数, 最高位为0:

id is composed of:
   time - 41 bits (millisecond precision w/ a custom epoch gives us 69 years)
   configured machine id - 10 bits - gives us up to 1024 machines
   sequence number - 12 bits - rolls over every 4096 per machine (with protection to avoid rollover in the same ms)

机器 id 共占 10 bit(5 bit 数据中心id, 5 bit 工作id), 最大即为 1024; 时间戳精确到毫秒, 占 41 bit(比如1490842567501 精确到了毫秒), 每次生成新的 id 的时候需要获取当前的系统时间, 再分两种情况生成 sequence number:

如果当前的时间和前一个已生成的时间相同(同一毫秒), 就用前一个 id 的 `sequence number + 1` 作为新的 sequence number; 如果本毫秒的 id 用完就等到下一毫秒继续(等待过长中不能分配新的id);

如果当前的时间比前一个 id 的时间大, 随机生成一个初始的 sequence number 作为本毫秒内的第一个 sequence number;

整个过程中, 只在 worker 启动的时候会对外部有依赖(从 zookeeper 获取 worker 号), 以后就可以独立工作, 做到了去中心化; 另外如果是异常情况下:

获取的当前时间小于上一个 id 的时间, twitter 的做法则是继续获取当前机器的时间直到获取到更大的时间才能继续工作(等待的过程中不能分配新的 id);

从这点看如果机器的时钟偏差较大, 整个系统则不能正常工作, snowflake 文档中也做了相应的提示, 使用 ntp 同步系统时钟, 同时将 ntp 配置成不会向后调整的模式, 详见: Time_synchronization

System Clock Dependency

 You should use NTP to keep your system clock accurate.  Snowflake protects from non-monotonic clocks, i.e. clocks that run
 backwards.  If your clock is running fast and NTP tells it to repeat a few milliseconds, snowflake will refuse to generate ids
 until a time that is after the last time we generated an id. Even better, run in a mode where ntp won't move the clock
 backwards. See http://wiki.dovecot.org/TimeMovedBackwards#Time_synchronization for tips on how to do this.

参见: Unique-ID

2. last_insert_id 方式

详见: flickr

如果使用 MySQL 作为序列号的服务, 就不能使用 uuid, 这个问题同 snowflake 中介绍的, 也不能使用 md5, guid 等, 这些太散列, 不利于索引的创建和查找; flickr 的文章的介绍了使用 MySQL 自增id 的方式实现序列号的生成. 这种方式也是很多中小业务使用的方式, 不过很多都使用了 InnoDB 引擎:

创建 ticket 相关表:

CREATE TABLE `Tickets64` (
  `id` bigint(20) unsigned NOT NULL auto_increment,
  `stub` char(1) NOT NULL default '',
  PRIMARY KEY  (`id`),
  UNIQUE KEY `stub` (`stub`)
) ENGINE=MyISAM

REPLACE INTO Tickets64 (stub) VALUES ('a');
SELECT LAST_INSERT_ID();

replace 语句在存在唯一键或主键冲突的时候, 会加一个互斥的 next-key 锁, 以免在查询或索引扫描的时候出现幻读的现象, 详见: innodb-locks-set 但是这也会引来一个问题, 多个线程并发更新的时候容易产生死锁, MyISAM 引擎的效果较好, 但不利于 innobackupex 在线备份, 记录很少的情况下可以改为 MyISAM 引擎.

单一业务使用这种方式是个很好的解决方案. 如果需要更好的性能可以采用双主的架构, 不过需要设置好各自的自增键的偏移值和步长.

3. MariaDB Sequence 介绍

MariaDB 10.0.3 版本引入了新的引擎: Sequence , 不同于 postgresql, MariaDB 的 sequence 比较特殊, 它是一个虚拟的, 临时的自增序列, 会话结束后序列便消失, 没有持久化功能, 也不能被其它表像自增主键那样引用. sequence 根据表的名字确定边界和自增值.

如何使用

  1. 边界和自增值由表名决定, 生成 1 ~ 5 的序列
SELECT * FROM seq_1_to_5;
+-----+
| seq |
+-----+
|   1 |
|   2 |
|   3 |
|   4 |
|   5 |
+-----+
  1. 以 3 为步长生成 1 ~ 15 的序列
SELECT * FROM seq_1_to_15_step_3;
+-----+
| seq |
+-----+
|   1 |
|   4 |
|   7 |
|  10 |
|  13 |
+-----+
  1. 递减生成序列
SELECT * FROM seq_5_to_1_step_2;
+-----+
| seq |
+-----+
|   5 |
|   3 |
|   1 |
+-----+

note: 如果启用了 sequence 引擎, 新建的表名不能和序列的表名冲突, 临时表可以和序列表名一样

MariaDB sequence 误区

MariaDB sequence 引擎不像 PostgreSQL 和 FirebirdSQL 的序列生成器, 生存期仅为当前语句的执行时间, 没有持久化功能, 也没有 nextval 相关的功能.

sequence 也不能生成负数序列, 在达到最大/最小边界的时候不能轮询(类似 PostgreSQL 序列生成器的 CYCLE 选项).

MariaDB sequence 使用场景

详细使用参见 mariadbs-sequence

1. 找出列中的空洞行
2. 生成组合数
3. 生成两个数的公约数
4. 生成排序的字符
5. 生成排序的日期时间等

4. postgresql 序列生成器

postgresql 自带的序列生成器能够很好的实现序列数的需求, 类似 MySQL 的 last_insert_id 方式. 不过 postgresql 的序列包含以下特性:

1. 序列可以用于表中的多个字段;
2. 序列可以被多个表共用;

创建序列见: sql-createsequence 语法较丰富, 支持很多参数, 可以设置序列的起始值, 上限值, cache 和是否循环等. 序列函数见: functions-sequence

操作序列的函数包括

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

程序调用 currval 函数之前, 都需要执行过 nextval 函数.如果 setval 的 is_called 为 false, 则下次调用 nextval 函数将范围其声明的值, 再次调用 nextval 才会开始递增序列.

regclass 类型为相关函数的参数, 这里即序列的名称. 如下所示:

cztest=# create sequence seq1;
CREATE SEQUENCE
cztest=# select nextval('seq1');
 nextval 
---------
       1
(1 row)

cztest=# select nextval('seq1');
 nextval 
---------
       2
(1 row)

cztest=# select currval('seq1');
 currval 
---------
       2
(1 row)

cztest=# select setval('seq1', 1, false);
 setval 
--------
      1
(1 row)

cztest=# select nextval('seq1');
 nextval 
---------
       1
(1 row)

cztest=# select nextval('seq1');
 nextval 
---------
       2
(1 row)

使用序列生成器经常碰到的问题

  1. 事务回滚后, 序列不会回滚

  2. 序列的范围基于 bigint 运算, 其范围不超过 8 字节的整数范围.一些老的系统不支持 8 字节的编译器则采用普通的 4 字节 int 运算.

  3. 序列达到上限后, 默认不加 CYCLE 选项, 则会报错, 不允许生成序列, 如果加了 CYCLE 选择, 则从开始值重新生成.

  4. 如果 cache 大于 1, 意味着该会话一次取多个序列, 每次访问序列对象的过程中都将分配并缓存随后的序列值, 并且相应的增加序列对象的 last_value. 从这点看 cache 越大意味着序列的性能越高. 不过同一个事务中随后的 cache – 1 次 nextval 将只返回预先分配的值, 在会话结束前没有使用剩下的值, 会导致序列里出现空洞(不连续). 另外如果有多个会话并发操作同一个序列生成器, 在业务层面来看可能会产生无序的问题, 在 cache 大于 1 的时候, 只能保证 nextval 值唯一, 不能保证顺序生成; 最后, 如果在序列上执行 setval, 则其它会话不会发觉, 直到用光缓存的数为止.

总结

在上述介绍的四种 id 生成方式中, MariaDB 的 sequence 不适合序列生成器的需求. 很多中小业务使用的都是基于 MySQL 的 last_insert_id 方式. 这种方式在单一业务中使用方便, 有多少业务就创建多少对应的表, 不太使用具有分布式特性的业务. 另外很多开源的工具, 如 idgo 就是基于该方式, 只是提供了 redis 协议兼容的接口, 创建多个序列及意味着映射了多个 MySQL 表, 在并发较大的场景下不能避免死锁的发生.

而 PostgreSQL 的序列生成器则是内置的功能, 有很丰富的操作函数, 并发方面比起 MySQL 方式有较好的性能, 比较流行的开源工具 postgrestprest 都提供了 http 接口, 已有的程序改造起来也比较轻松方便.

snowflake 方式则比较适合分布式场景的业务, 对时间依赖较强的业务也可以使用该方式, 另外这种方式在性能方面应该是最好的. 已有的开源工具如 sonygoSnowFlake 都做了比较好的实现, 以 http 接口对外服务, 程序改造起来也比较方便. 不过与上述的两种方式相比, 开源的工具并未实现持久化和高可用的功能, 在服务中断的情况下难以继续生成相应的序列, 需要我们做相应的二次开发.

Leave a Reply

Your email address will not be published. Required fields are marked *