✍🏻 一、需求
生成一个id,策略为:固定头(2位)+ 时间月日(四位)+ 随机数字(6位)
例如 : 010312666666
🥛 二、分析
分析可以知道,当日最多可以生成一百万个id,从010312000000 - 010312999999
比较麻烦的就是如何生成不冲突的后六位的随机数字
☠️ 三、方案设计
3.1 维护已生成id的集合缓存
3.1.1 步骤
- Redis每日晚上23:59:00 生成一个key,例:code_03_12,数据结构为Set
- 随机算法生成一个六位数据
- Redis Set.Sismember校验是否存在
存在则递归回到第二步,不存在则Set.add放入Redis
3.1.2 优点
- 内存级别,生成速度极快
- Redis单线程,锁友好,不用考虑额外加锁
3.1.3 缺点
这个方法存在一个致命问题,就是采用了递归算法,越往后碰撞越大,时间复杂度越高。线程可能会一直被卡在获取id中,甚至进入死循环。
3.2 预生成策略-在Redis中维护待使用的id集合
3.2.1 步骤
- Redis每日晚上23:59:00生成一个key,写入一百万个待使用的id
- 使用Redis Set.SPOP 移除并返回集合中的一个随机元素
- 没有值返回则说明id使用完成了
3.2.2 优缺点
优点和方案一一样,而且生成方式不再使用递归,时间复杂度O(1),可以说是十分完美了。
但是如果再考究的细致一些,其实还是有一点点缺点的。
因为这个key存储了一百万条数据,每条数据是六位随机数字,当做一个整数,是4个字节,那这个key所占的内存就是:,即大约4MB的内存。
我又查了一下资料,4mb的key算不算是大key呢?
chatgpt4的回答是:
Answer from chatgpt4:
Redis并没有严格定义什么是"大key",这主要取决于你的应用场景和Redis服务器的配置。然而,一般来说,如果一个key的大小超过1MB,那么它可能被认为是一个大key。
大key的问题主要在于,当Redis对一个大key进行操作时,可能会消耗大量的CPU和内存资源,导致Redis服务器的性能下降。特别是当Redis进行全键扫描(例如使用KEYS命令)或者在复制(replication)和RDB/AOF持久化时,大key可能会导致严重的性能问题。
所以,尽管4MB的key在许多情况下可能不会引起问题,但是为了避免可能的性能问题,最好将数据分散到多个较小的key中,而不是存储在一个大key中。
我们较真一些,就打算这个是个大key,在此基础上再进行一下方案的优化。
3.2.3 优化后的步骤
- Redis每日晚上23:59:00生成一百个key,每个key存入一万个待使用的id,占用内存4kb
- 使用的时候,可以按顺序使用这一百个key,每个key用完以后从Redis中删除
3.3 预生成策略-在MySQL中维护待使用的id集合
方案二已经非常不错了,但是考虑到Redis中间件本身存在的不可靠性(节点分片挂掉,aof和rdb都会有几率丢失生成的key),所以考虑一个在MySQL维护的方案。
3.3.1 步骤
基本步骤和方案二一样
- 设计表
- 写入递增的一百万条数据
- 使用时随机拿取一条is_used = 0的数据,并将is_used置为1
- 每日00:00:00,将is_used = 1的数据再重新置为0即可
CREATE TABLE `t_trade_code` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`trade_code` varchar(36) DEFAULT NULL,
`is_used` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;其中需要注意一个细节,因为生成数据的时候是递增的,从000000到999999,但是取数据的时候得随机取,这就涉及到如何使用sql随机取数据了。
- 方法一 rand函数:
select * from t_trade_code where is_used = 0 order by rand() limit 1LIMIT 1 的执行时间:0.559s, LIMIT 100 耗时 0.660s。
此种方法在数据量小的情况下可以使用,但在生产环境不建议使用。
MySQL手册里面针对 RAND() 的提示大概意思就是,在 ORDER BY 从句里面不能使用 RAND() 函数,因为这样会导致数据列被多次扫描,导致效率相当相当的低,效率不行,切忌使用。
- 方法二 rand函数+join:
SELECT * FROM t_trade_code AS t1 JOIN (SELECT ROUND(RAND()*(SELECT MAX(id)
FROM t_trade_code)) AS id) AS t2 WHERE t1.id>=t2.id ORDER BY t1.id LIMIT 1;此 sql 随机获取一条的时间是 0.014s,LIMIT 100 时耗时 0.020s,性能上没差多少。
获取多条的话有时会达不到要求(获取的记录数可能达不到多条)
- 方法三 子查询+rand函数
SELECT * FROM t_trade_code as t1 WHERE t1.id>=(RAND()*(SELECT MAX(id) FROM t_trade_code)) LIMIT 1;随机取一条的耗时:0.015,LIMIT 100 时耗时 0.026s。
随机获取一条记录推荐使用 第 2 种方法,在 30 万条记录时也只需 0.014s。
上边第二种方法更优写法:
SELECT * FROM t_trade_code AS t1 JOIN (SELECT ROUND(RAND() * ((SELECT MAX() FROM `t_trade_code`)-(SELECT MIN(id) FROM t_trade_code))+(SELECT MIN(id) FROM users)) AS id) AS t2 WHERE t1.id >= t2.id ORDER BY t1.id LIMIT 1第三种方法的更优写法:
SELECT * FROM t_trade_code WHERE id >= ((SELECT MAX(id) FROM t_trade_code)-(SELECT MIN(id) FROM t_trade_code)) * RAND() + (SELECT MIN(id) FROM t_trade_code) LIMIT 1最后,我采用了方法二,使用接口测试,整个接口也就20~40ms即可返回。
3.3.2 缺点
直接说一下可能存在的问题,当高并发情况下,两个线程同时查询表获取数据,两个线程可能会获取到相同的code,这个几率小,但是也是客观存在的原因。
3.3.3 解决
使用悲观锁即可解决,最终sql为:
SELECT * FROM t_trade_code AS t1 JOIN (SELECT ROUND(RAND()*(SELECT MAX(id)
FROM t_trade_code)) AS id) AS t2 WHERE t1.id>=t2.id ORDER BY t1.id LIMIT 1
for update;当一个线程开启事务,并使用悲观锁读取数据时,其他线程就无法同时读取相同的记录。但其他线程会被卡住,等待此线程更新数据提交完事务后才能执行结束此查询语句。
🎯 四、总结
最后采用的方案是方案三,在MySQL中维护预生成的id,当然这个方案也不是百分百完美的。因为使用了行锁,所以效率很低。可以考虑将行锁更换为Redis或者zk的分布式锁。因为使用到了定时任务,还要考虑是否要引入xxl-job的问题。
针对这次需求,我还想到了其他方案,但是都不太成熟就不在这里一一写下了。方案的设计需要有足够的知识积累还有和大家充分的讨论,才能让方案设计最完善合理。
如果需要生成随机且不会产生碰撞的6位数字,可以参考本文。