动机
为什么想到用UUID做数据库主键呢?考虑如此场景,有多个生产环境各自运行,有时要从一个环境导数据到另一个环境,因此要求主键不冲突,自增整数不能满足要求。
搞个中央发号器服务如何?很多互联网公司都这么做了。但对于以上场景,那就需要各个生产环境依赖同一个发号器服务了,难以跨数据中心乃至跨地域。
其实这需要一种去中心化的发号策略,无论哪个服务器都可以发号,而且这些号互不冲突。
有一种现成的去中心化发号的实现,这就是UUID!UUID叫做通用唯一标识符(Universally Unique Identifier),是128bit的整数,用算法计算得到。在分布式系统中的任一服务器只要依标准创建UUID值,就能保证在全局范围不重复。
例如Java的UUID.randomUUID()是用SecureRandom实现的完全随机数,这是一种在密码学意义上安全的随机数,随机位越多越安全(猜不到规律,而且不容易重复)。由于有112bit随机位,理论上认为在地球上是不会重复的。
考察
就不多介绍了,来讲讲它能不能作为数据库主键吧,看看优缺点:
优点
- 去中心化生成->无单点风险
- 去中心化生成->无需特意设计就能并行生成(自增主键是串行生成的,比较慢)
- 无状态生成->数据记录在存入数据库之前就能拥有主键(自增主键要在数据记录存入数据库后才能获取),编程更容易
缺点
- 无序->不能用主键排序代替时间排序,以避免加载数据记录(个别实现能有序,但Java内置实现完全无序)
- 无序->降低数据库写入性能
- 无序->降低数据库读取性能
优点不用多说了,来讨论这些缺点吧。
首先弄清楚关系型数据库系统是怎么工作的。类似于文件系统,关系型数据库系统以页为单位来存放数据,一页(一般为4KB、8KB或16KB大小)能存放一批数据记录。读写时也是整页地读取或写入。对于有主键列的表,默认提供主键索引,索引项包含了主键值并且以主键排序。
MySQL的主键索引采用聚簇索引,索引与数据融为一体,数据记录按照主键顺序来存储。
PostgreSQL的主键索引采用非聚簇索引,索引与数据各存一份(索引相当于"主键值->数据存放地址"的映射表),有专门的索引页和数据页。
(1) 不能用主键排序代替时间排序,以避免加载数据记录。
当一个查询只需要返回主键但需要按时间排序时,如果主键是时间有序的,就可以对主键排序。这时只需访问索引而无需访问数据记录就能完成查询,因为索引就包含了主键值。
(2) 降低数据库写入性能
用INSERT语句添加数据记录时,要更新主键索引。UUID主键的随机性使得MySQL的数据页、PostgreSQL的索引页被随机写入,很可能一页只添加一行,因此需要读写很多页(从磁盘读入内存,修改再写回,命中缓存时不用读磁盘但仍要写回)。有序的主键更有可能把多行添加到同一页,因此可以写更少的页(数据库系统不会每写一行就刷盘,而会稍微缓冲一小会,让一页有可能多收到几行)。简而言之,随机主键不能利用数据的空间局部性。
PostgreSQL只是索引页受影响,比MySQL数据页受影响要好些。但PostgreSQL WAL的full_page_writes特性引起的写入放大使它也受不小的影响。
(3) 降低数据库读取性能
相邻时间添加的数据记录会随机分布在MySQL的很多个数据页。新建的数据记录可能比较热门,但是它们随机放在很多个数据页,没有哪一页是热门的。而且一些范围查询,例如按创建时间查询,需要读取很多个数据页才能拿到所有匹配的数据记录。
同样,PostgreSQL只是索引页受影响,比MySQL数据页受影响要好些。
还有一个问题是UUID(128bit)比BIGINT(64bit)大,若用char(32)来保存UUID则需要256bit,存储和计算的开销都更大。
这篇是Percona首席架构师的文章,讨论MySQL使用UUID主键的性能。
https://www.percona.com/blog/...
这两篇是一位PostgreSQL专家的文章,讨论PostgreSQL使用UUID主键的性能,以及full_page_writes遇到的写入放大问题。
https://www.2ndquadrant.com/e...
https://www.2ndquadrant.com/e...
URL编码
再考虑URL编码的问题。主键需要在REST风格URL中用来标识资源,例如/users/{id},id可以是任一主键值。此时主键要被编码为字符串,UUID的字符串形式至少也要32字节(若保留'-'分隔符,如Java UUID.toString(),则为36字节)。这个字符串形式实际是UUID的16进制写法。
如果对UUID做Base64编码,可以压缩到22字节。请注意要使用URL Safe Base64编码(Java提供这一编码模式),因为标准的Base64含有URL保留字符,使得id字符串需要被转义。
有一些版本的UUID是有序的,其字符串形式满足ASCII顺序,Base64编码使其不遵守ASCII顺序。可使用Firebase-style Base64编码,参见 https://firebase.googleblog.c... 。
有序ID
可以同时获得时间有序性和安全随机性的好处吗?可以。
一种时间有序的去中心化唯一ID实现:
- 生成一个完全随机的UUID,长度为128bit
- 以当前unix time作前缀,这样总长度为192bit
- Firebase-style Base64编码,这样总长度为32字节,相当于普通UUID string
一种更小巧的实现是unix time配上64bit随机值,和UUID一样有32字节,Firebase-style Base64编码后长度为22字节。但是可能对碰撞率有影响,需要理论证明。
还有一种实现是48bit unix time配上80bit随机数,编码前char(32),编码后char(22),这一类算法有Firebase在生产环境用了,推荐。
Cassandra使用UUID v1,依赖微秒时间和MAC地址。
有序ID的编码
Firebase-style Base64有一个问题:当前的ID总是以'-'开始。用户体验不好,因为用户很容易忽略这个字符,以为它不是ID的成分。
因此我重新设计了编码,还赠送一份Java实现代码给读者(48bit unix time配上80bit随机数,满足ASCII顺序的URL Safe Base64编码)。
/**
* (22 chars) 48bit milliseconds + 80bit random value (ASCII-ordered URL-safe Base64-encoded)
*/
public final class UniqueIdUtil {
private static final SecureRandom secureRandom = new SecureRandom();
private static final byte[] remapper = new byte[128];
static {
byte[] oldCodes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".getBytes(StandardCharsets.US_ASCII);
byte[] newCodes = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~".getBytes(StandardCharsets.US_ASCII);
for (int i = 0; i < oldCodes.length; i++) {
remapper[oldCodes[i]] = newCodes[i];
}
}
private UniqueIdUtil() {}
public static String newId() {
ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[18]);
byteBuffer.putLong(System.currentTimeMillis());
byte[] randomBytes = new byte[10];
secureRandom.nextBytes(randomBytes);
byteBuffer.put(randomBytes);
return newId(byteBuffer);
}
static String newId(ByteBuffer byteBuffer) {
byte[] original = Arrays.copyOfRange(byteBuffer.array(), 2, 18);
byte[] encoded = Base64.getUrlEncoder().withoutPadding().encode(original);
for (int i = 0; i < encoded.length; i++) {
encoded[i] = remapper[encoded[i]];
}
return new String(encoded, StandardCharsets.US_ASCII);
}
}
来比较两种ID编码:
Firebase-style Base64编码:-MPZFw-83QdUZ_vQ6UAMdF
自制Base64编码:0NQ_LnK8m~Cv5uYuAOTzUG
是不是效果更好?
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。