前言
先说一下id吧,ID(Identity document),即身份信息,无论是生活中还是软件设计中,id都是用于辨识身份的,id在数据库的设计中也往往是主键。那么为什么要自定义id呢?
通常因为业务上常有以下需求:
- 全局唯一性:不能出现重复的ID号,既然是唯一标识,这是最基本的要求。
- 趋势递增:在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。
- 单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求。
- 信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则、不规则。
mysql免费常见,市场占有率广,我们往往在练习中使用mysql,而id都是使用整数Int型自增id,但实际上在生成中不应该使用,不安全,容易被推测出id,特别是前面的id的数据,容易被攻击。数据库自身的主键id自增已经不能满足需求了,所以就会出现我们使用自定义id插入。
还有我们知道在mysql数据库中,如果主键使用的整数型,速度会比字符串快,但是以字符串为代表的主键大多数是通过精密算法得出的,可靠性和唯一性更高,典型代表就是UUID啦,扛把子没得说,那接下来就看一下几种思路的自定义id啦
那么要自定义id,也要自定义id类型和数据库类型能对上才行,那我们先了解以下mysql有哪些数据类型,以及其默认长度吧
MySQL 数据类型
数值类型
MySQL支持所有标准SQL数值数据类型。
这些类型包括严格数值数据类型(INTEGER、SMALLINT、DECIMAL和NUMERIC),以及近似数值数据类型(FLOAT、REAL和DOUBLE PRECISION)。
关键字INT是INTEGER的同义词,关键字DEC是DECIMAL的同义词。
BIT数据类型保存位字段值,并且支持MyISAM、MEMORY、InnoDB和BDB表。
作为SQL标准的扩展,MySQL也支持整数类型TINYINT、MEDIUMINT和BIGINT。下面的表显示了需要的每个整数类型的存储和范围。
类型 | 大小 | 范围(有符号) | 范围(无符号) | 用途 |
---|---|---|---|---|
TINYINT | 1 byte | (-128,127) | (0,255) | 小整数值 |
SMALLINT | 2 bytes | (-32 768,32 767) | (0,65 535) | 大整数值 |
MEDIUMINT | 3 bytes | (-8 388 608,8 388 607) | (0,16 777 215) | 大整数值 |
INT或INTEGER | 4 bytes | (-2 147 483 648,2 147 483 647) | (0,4 294 967 295) | 大整数值 |
BIGINT | 8 bytes | (-9,223,372,036,854,775,808,9 223 372 036 854 775 807) | (0,18 446 744 073 709 551 615) | 极大整数值 |
FLOAT | 4 bytes | (-3.402 823 466 E+38,-1.175 494 351 E-38),0,(1.175 494 351 E-38,3.402 823 466 351 E+38) | 0,(1.175 494 351 E-38,3.402 823 466 E+38) | 单精度 浮点数值 |
DOUBLE | 8 bytes | (-1.797 693 134 862 315 7 E+308,-2.225 073 858 507 201 4 E-308),0,(2.225 073 858 507 201 4 E-308,1.797 693 134 862 315 7 E+308) | 0,(2.225 073 858 507 201 4 E-308,1.797 693 134 862 315 7 E+308) | 双精度 浮点数值 |
DECIMAL | 对DECIMAL(M,D) ,如果M>D,为M+2否则为D+2 | 依赖于M和D的值 | 依赖于M和D的值 | 小数值 |
日期和时间类型
表示时间值的日期和时间类型为DATETIME、DATE、TIMESTAMP、TIME和YEAR。
每个时间类型有一个有效值范围和一个"零"值,当指定不合法的MySQL不能表示的值时使用"零"值。
TIMESTAMP类型有专有的自动更新特性,将在后面描述。
类型 | 大小 ( bytes) | 范围 | 格式 | 用途 |
---|---|---|---|---|
DATE | 3 | 1000-01-01/9999-12-31 | YYYY-MM-DD | 日期值 |
TIME | 3 | ‘-838:59:59’/‘838:59:59’ | HH:MM:SS | 时间值或持续时间 |
YEAR | 1 | 1901/2155 | YYYY | 年份值 |
DATETIME | 8 | 1000-01-01 00:00:00/9999-12-31 23:59:59 | YYYY-MM-DD HH:MM:SS | 混合日期和时间值 |
TIMESTAMP | 4 | 1970-01-01 00:00:00/2038结束时间是第 2147483647 秒,北京时间 2038-1-19 11:14:07,格林尼治时间 2038年1月19日 凌晨 03:14:07 | YYYYMMDD HHMMSS | 混合日期和时间值,时间戳 |
字符串类型
字符串类型指CHAR、VARCHAR、BINARY、VARBINARY、BLOB、TEXT、ENUM和SET。该节描述了这些类型如何工作以及如何在查询中使用这些类型。
类型 | 大小 | 用途 |
---|---|---|
CHAR | 0-255 bytes | 定长字符串 |
VARCHAR | 0-65535 bytes | 变长字符串 |
TINYBLOB | 0-255 bytes | 不超过 255 个字符的二进制字符串 |
TINYTEXT | 0-255 bytes | 短文本字符串 |
BLOB | 0-65 535 bytes | 二进制形式的长文本数据 |
TEXT | 0-65 535 bytes | 长文本数据 |
MEDIUMBLOB | 0-16 777 215 bytes | 二进制形式的中等长度文本数据 |
MEDIUMTEXT | 0-16 777 215 bytes | 中等长度文本数据 |
LONGBLOB | 0-4 294 967 295 bytes | 二进制形式的极大文本数据 |
LONGTEXT | 0-4 294 967 295 bytes | 极大文本数据 |
注意:char(n) 和 varchar(n) 中括号中 n 代表字符的个数,并不代表字节个数,比如 CHAR(30) 就可以存储 30 个字符。
CHAR 和 VARCHAR 类型类似,但它们保存和检索的方式不同。它们的最大长度和是否尾部空格被保留等方面也不同。在存储或检索过程中不进行大小写转换。
BINARY 和 VARBINARY 类似于 CHAR 和 VARCHAR,不同的是它们包含二进制字符串而不要非二进制字符串。也就是说,它们包含字节字符串而不是字符字符串。这说明它们没有字符集,并且排序和比较基于列值字节的数值值。
BLOB 是一个二进制大对象,可以容纳可变数量的数据。有 4 种 BLOB 类型:TINYBLOB、BLOB、MEDIUMBLOB 和 LONGBLOB。它们区别在于可容纳存储范围不同。
有 4 种 TEXT 类型:TINYTEXT、TEXT、MEDIUMTEXT 和 LONGTEXT。对应的这 4 种 BLOB 类型,可存储的最大长度不同,可根据实际情况选择。
自定义id实现思路
时间戳加随机数
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ThreadLocalRandom;
public class IdGenerator {
//选择日期格式
static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
//封装时间戳加随机数方法
public static String timestamp() {
//获取当前时间
LocalDateTime now = LocalDateTime.now();
//使用指定日期格式
String format = now.format(formatter);
//返回当前时间戳+三位随机数策略
return format + (ThreadLocalRandom.current().nextInt(999) + 1);
}
//遍历,测试(效果同下)
public static void main(String[] args) {
LocalDateTime now = LocalDateTime.now();
String format = now.format(formatter);
for (int i = 0; i < 10; i++) {
System.out.println(format + (ThreadLocalRandom.current().nextInt(999) + 1));
}
System.out.println("=======================================");
//使用静态方法调用封装方法
for (int j=0;j<10;j++){
System.out.println(IdGenerator.timestamp());
}
}
}
测试结果
多测几次可见,其特点是可以生成20位以内的id,而且基本趋于增势,而且是整数id,安全性一般,但是在性能上还是有一定保证,出现不足20位的原因是秒值和毫秒值有可能出现为0的情况,所以只能说基本趋于增势,在并发量不是超高的情况下还是可以考虑的,但是如果要求id位数固定,就要自行改造了,就要补0或者换一种时间戳实现,特别是如果是税务要求固定20位的时候。其数据库字段可以对应bigint。可修改为以下方法可满足:
时间戳加递增数定长增长id
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* 获取主键:返回17位时间戳+3位递增数(同一时间递增)
*/
public class IdCreator {
private static int addPart = 1;
private static String result = "";
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmssSSS");
private static String lastDate = "";
/**
* 获取主键
* @param length 长度
* @return 返回17位时间戳+3位递增数
*/
public synchronized static String getId(int length) {
//获取时间部分字符串
Date now = new Date();
String nowStr = sdf.format(now);
//获取数字后缀值部分
if (IdCreator.lastDate.equals(nowStr)) {
addPart += 1;
} else {
addPart = 1;
lastDate = nowStr;
}
if (length > 17) {
length -= 17;
for (int i = 0; i < length - ((addPart + "").length()); i++) {
nowStr += "0";
}
nowStr += addPart;
result = nowStr;
} else {
result = nowStr;
}
//20200827152917060001
return result;
}
public static void main(String[] args) {
//模拟测试功能
for (int i = 0; i <10 ; i++) {
System.out.println(IdCreator.getId(20));
}
System.out.println("================================");
//静态调用演示
for (int i = 0; i <10 ; i++) {
System.out.println(IdCreator.getId(20));
}
}
}
此处输出的是18位,java种可以使用long装配为主键,要满足税务发票编号要求,20位,特点是单向递增,可有序排列,性能尚可,但是安全性偏低。上面20位可以就可以在return那里不截取了,就完美实现啦,是不是也是棒棒哒。
年份+毫秒值时间戳+三位数递增的定长方法
有人说你这些都太长了,我就要long直接可以接受的id怎么办?那我们就使用截取将毫秒值的开头截取两位,就变4+11+3了。
import java.text.SimpleDateFormat;
public class GuidUtils {
/**
* 获取18位随机数
* 4位年份+11位时间戳(毫秒值截取2位)+3位随机数
* @author
*/
public static void main(String[] args) {
for (int i = 0; i <10 ; i++) {
//调用生成id方法
System.out.println(GuidUtils.getGuid());
}
System.out.println("======");
System.out.println(System.currentTimeMillis());
}
/**
* 时间戳后的末尾的数字id
*/
public static int Guid=100;
//多线程下使用volitale修饰
//public static volitale int Guid=100;
public static String getGuid() {
GuidUtils.Guid+=1;
long now = System.currentTimeMillis();
//获取4位年份数字
SimpleDateFormat dateFormat=new SimpleDateFormat("yyyy");
//获取时间戳
String time=dateFormat.format(now);
String info=now+"";
//获取三位随机数
//int ran=(int) ((Math.random()*9+1)*100);
//要是一段时间内的数据连过大会有重复的情况,所以做以下修改
int ran=0;
if(GuidUtils.Guid>999){
GuidUtils.Guid=100;
}
ran=GuidUtils.Guid;
return time+info.substring(2, info.length())+ran;
}
}
这个方案总的来说还是感觉挺不错的,方便,也不会太长,long类型即可接收,而且是定长,也有递增,当然觉得递增还是不够安全,就改成随机数也是可以的。
UUID
UUID(Universally Unique Identifier)的标准型式包含32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的36个字符,示例:550e8400-e29b-41d4-a716-446655440000,到目前为止业界一共有5种方式生成UUID,详情见IETF发布的UUID规范。
优点:
- 性能非常高:本地生成,没有网络消耗。
缺点:
-
不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用。
-
信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
-
ID作为主键时在特定的环境会存在一些问题,比如做DB主键的场景下,UUID就非常不适用:
-
① MySQL官方有明确的建议主键要尽量越短越好[4],36个字符长度的UUID不符合要求。
All indexes other than the clustered index are known as secondary indexes. In InnoDB, each record in a secondary index contains the primary key columns for the row, as well as the columns specified for the secondary index. InnoDB uses this primary key value to search for the row in the clustered index.*** If the primary key is long, the secondary indexes use more space, so it is advantageous to have a short primary key***.
② 对MySQL索引不利:如果作为数据库主键,在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能。
所以直接用uuid作为主键其实还是比较一般的,如果是普通的设计可以直接使用uuid作为主键,至少保证了全局唯一性。
import java.util.UUID;
public class UUIDUtils {
/**
* 带-的UUID
*
* @return 36位的字符串
*/
public static String getUUID() {
return UUID.randomUUID().toString();
}
/**
* 去掉-的UUID
*
* @return 32位的字符串
*/
public static String getUUID2() {
return UUID.randomUUID().toString().replace("-", "");
}
public static void main(String[] args) {
for (int i = 0; i <5; i++) {
System.out.println(UUIDUtils.getUUID());
}
System.out.println("================================");
for (int i = 0; i <5 ; i++) {
System.out.println(UUIDUtils.getUUID2());
}
}
}
测试结果如上。
那如果觉得这样会造成InnoDB无序性怎么办?可以考虑ID物理主键+UUID逻辑主键
- 优点:
InnoDB会对主键进行物理排序,这对auto_increment_int类型有好处,因为后一次插入的主键位置总是在最后。但是对uuid来说则有缺点,因为uuid是杂乱无章的,每次插入的主键位置是不确定的,可能在开头,也可能在中间,在进行主键物理排序的时候,势必会造成大量的IO操作影响效率。
缺点:
- 同自增ID的缺点:全局值加锁解锁以保证增量的唯一性带来的性能问题。还有一个就是对于数据库有相关性单独用uuid和上面的时间戳结合方法其实可以避免数据库相关性,再更换数据库的时候可以平滑过渡。
自增id+UUID的方法在分布式里面还是挺常见的设计模式。
雪花算法,类雪花算法
/**
* Twitter_Snowflake<br>
* SnowFlake的结构如下(每部分用-分开):<br>
* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
* 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>
* 41位时间戳(毫秒级),注意,41位时间戳不是存储当前时间的时间戳,而是存储时间戳的差值(当前时间戳 - 开始时间戳)
* 得到的值),这里的的开始时间戳,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间戳,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
* 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>
* 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间戳)产生4096个ID序号<br>
* 加起来刚好64位,为一个Long型。<br>
* SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
*/
public class SnowflakeIdWorker {
// ==============================Fields===========================================
/** 开始时间截 (201-01-01) */
private final long twepoch = 1514736000000L;
/** 机器id所占的位数 */
private final long workerIdBits = 5L;
/** 数据标识id所占的位数 */
private final long datacenterIdBits = 5L;
/** 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数) */
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
/** 支持的最大数据标识id,结果是31 */
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
/** 序列在id中占的位数 */
private final long sequenceBits = 12L;
/** 机器ID向左移12位 */
private final long workerIdShift = sequenceBits;
/** 数据标识id向左移17位(12+5) */
private final long datacenterIdShift = sequenceBits + workerIdBits;
/** 时间截向左移22位(5+5+12) */
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
/** 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095) */
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
/** 工作机器ID(0~31) */
private long workerId;
/** 数据中心ID(0~31) */
private long datacenterId;
/** 毫秒内序列(0~4095) */
private long sequence = 0L;
/** 上次生成ID的时间截 */
private long lastTimestamp = -1L;
//==============================Constructors=====================================
/**
* 构造函数
* @param workerId 工作ID (0~31)
* @param datacenterId 数据中心ID (0~31)
*/
public SnowflakeIdWorker(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
// ==============================Methods==========================================
/**
* 获得下一个ID (该方法是线程安全的)
* @return SnowflakeId
*/
public synchronized long nextId() {
long timestamp = timeGen();
//如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
//如果是同一时间生成的,则进行毫秒内序列
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
//毫秒内序列溢出
if (sequence == 0) {
//阻塞到下一个毫秒,获得新的时间戳
timestamp = tilNextMillis(lastTimestamp);
}
}
//时间戳改变,毫秒内序列重置
else {
sequence = 0L;
}
//上次生成ID的时间截
lastTimestamp = timestamp;
//移位并通过或运算拼到一起组成64位的ID
return ((timestamp - twepoch) << timestampLeftShift) //
| (datacenterId << datacenterIdShift) //
| (workerId << workerIdShift) //
| sequence;
}
/**
* 阻塞到下一个毫秒,直到获得新的时间戳
* @param lastTimestamp 上次生成ID的时间截
* @return 当前时间戳
*/
protected long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回以毫秒为单位的当前时间
* @return 当前时间(毫秒)
*/
protected long timeGen() {
return System.currentTimeMillis();
}
//==============================Test=============================================
public static void main(String[] args) {
SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
for (int i = 0; i < 10; i++) {
long id = idWorker.nextId();
System.out.println(Long.toBinaryString(id));
System.out.println(id);
}
}
}
运行结果如下:
就这样18位的id就闪亮登场啦,很赞,建议使用。
- 优点:
- 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。
不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的。 - 可以根据自身业务特性分配bit位,非常灵活。
- 缺点:
强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。
值得注意的是使用时间戳的都要注意避免机器的时钟回调!!
所以最好或者说强制做一个判断,时间戳小于正常时间戳的时候抛异常,等待恢复正常再重新复配,可以参考以下代码:
//发生了回拨,此刻时间小于上次发号时间
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
try {
//时间偏差大小小于5ms,则等待两倍时间
wait(offset << 1);//wait
timestamp = timeGen();
if (timestamp < lastTimestamp) {
//还是小于,抛异常并上报
throwClockBackwardsEx(timestamp);
}
} catch (InterruptedException e) {
throw e;
}
} else {
//throw
throwClockBackwardsEx(timestamp);
}
}
//分配ID
以下附上本文参考博客:
https://tech.meituan.com/2017/04/21/mt-leaf.html
https://www.jianshu.com/p/89dfe990295c
https://www.cnblogs.com/cs99lzzs/p/9869414.html
https://blog.csdn.net/xpf_user/article/details/79193052
https://www.cnblogs.com/qlqwjy/p/7530602.html
如觉得本文不错,想要转载,请注意备注本文出处。尤其是转载的公众号
或是其他资讯网站
。
源码托管于gitee,可去此处克隆或下载:https://gitee.com/calmtho/idtest