一 延时消息队列任务
1 延时消息队列介绍
(1)业务场景
例如在如下场景中可能会需要延时队列:
用户下订单成功之后隔20分钟给用户发送上门服务通知短信
订单完成一个小时之后通知用户对上门服务进行评价
业务执行失败之后隔10分钟重试一次
类似的场景比较多,简单的处理方式就是使用定时任务,假如数据比较多的时候,有的数据可能延迟比较严重,而且越来越多的定时业务导致任务调度很繁琐不好管理。
(2)开发前需要考虑的问题?
1.及时性:消费端能按时收到
2.同一时间消息的消费权重
3.可靠性:消息不能出现没有被消费掉的情况
3.可恢复:假如有其他情况,导致消息系统不可用了,至少能保证数据可以恢复
4.可撤回:因为是延迟消息,没有到执行时间的消息支持可以取消消费
5.高可用、多实例:这里指HA/主备模式并不是多实例同时一起工作
2 基于Redis的延迟队列
选用redis
作为数据缓存的主要原因是因为redis
自身支持zset
的数据结构(score 延迟时间毫秒) 这样就少了排序的烦恼而且性能还很高,正好我们的需求就是按时间维度去判定执行的顺序,同时也支持map
list
数据结构。
Zset本质就是Set结构上加了个排序的功能,除了添加数据value之外,还提供另一属性score,这一属性在添加修改元素时候可以指定,每次指定后,Zset会自动重新按新的值调整顺序。
可以理解为有两列字段的数据表,一列存value,一列存顺序编号。操作中key理解为zset的名字,那么对延时队列又有何用呢?
试想如果score代表的是想要执行时间的时间戳,在某个时间将它插入Zset集合中,它变会按照时间戳大小进行排序,也就是对执行时间前后进行排序,这样的话,起一个死循环线程不断地进行取第一个key值,如果当前时间戳大于等于该key值的socre就将它取出来进行消费删除,就可以达到延时执行的目的, 注意不需要遍历整个Zset集合,以免造成性能浪费。
/** * @program: test * @description: redis实现延时队列 * @author: xingcheng * @create: 2018-08-19 **/ public class DelayQueue { private static final String ADDR = "127.0.0.1"; private static final int PORT = 6379; private static JedisPool jedisPool = new JedisPool(ADDR, PORT); private static CountDownLatch cdl = new CountDownLatch(10); public static Jedis getJedis() { return jedisPool.getResource(); } /** * 生产者,生成5个订单 */ public void productionDelayMessage() { for (int i = 0; i < 5; i++) { Calendar instance = Calendar.getInstance(); // 3秒后执行 instance.add(Calendar.SECOND, 3 + i); DelayQueue.getJedis().zadd("orderId", (instance.getTimeInMillis()) / 1000, StringUtils.join("000000000", i + 1)); System.out.println("生产订单: " + StringUtils.join("000000000", i + 1) + " 当前时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())); System.out.println((3 + i) + "秒后执行"); } } //消费者,取订单 public static void consumerDelayMessage() { Jedis jedis = DelayQueue.getJedis(); while (true) { Set<Tuple> order = jedis.zrangeWithScores("orderId", 0, System.currenTimeMillis(),0,1);//表示取出0-当前时间戳的数据,从第0个数据开始,取一个 if (order == null || order.isEmpty()) { System.out.println("当前没有等待的任务"); try { TimeUnit.MICROSECONDS.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } continue; } Tuple tuple = (Tuple) order.toArray()[0]; double score = tuple.getScore(); Calendar instance = Calendar.getInstance(); long nowTime = instance.getTimeInMillis() / 1000; if (nowTime >= score) { String element = tuple.getElement(); Long orderId = jedis.zrem("orderId", element); if (orderId > 0) { System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + ":redis消费了一个任务:消费的订单OrderId为" + element); } } } } static class DelayMessage implements Runnable{ @Override public void run() { try { cdl.await(); consumerDelayMessage(); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { DelayQueue appTest = new DelayQueue(); appTest.productionDelayMessage(); for (int i = 0; i < 10; i++) { new Thread(new DelayMessage()).start(); cdl.countDown(); } } }
生产环境使用注意:
由于这种实现方式简单,但在生产环境下大多是多实例部署,所以存在并发问题,即缓存的查找和删除不具有原子性(zrangeWithScores和zrem操作不是一个命令,不具有原子性),会导致消息的多次发送问题,这个问题的避免方法如下:
1.可以采用单独一个实例部署解决(不具备高可用特性,容易单机出现故障后消息不能及时发送)
2.采用redis的lua脚本进行原子操作,即原子操作查找和删除(实现难度大)
参考:https://my.oschina.net/u/3266761/blog/1930360
3 基于环形数组实现
环形的任务队列,由数组实现,数组中元素是Set<Task>,数组长度是3600:
(1)环形队列,例如可以创建一个包含3600个slot的环形队列(本质是个数组)
(2)任务集合,环上每一个slot是一个Set<Task>
同时,启动一个timer,这个timer每隔1s,在上述环形队列中移动一格,有一个Current Index指针来标识正在检测的slot。
Task结构中有两个核心属性:
1. Cycle-Num:当Current Index第几圈扫描到这个Slot时,执行任务
2. Task-Function:需要执行的任务指针
启动一个Timer,每个一秒钟在移动一个slot,那转一圈正好需要一个小时。
如上图,当前Current Index指向第一格,当有延时消息到达之后,例如希望3610秒之后,触发一个延时消息任务,只需:
1. 计算这个Task应该放在哪一个slot,现在指向1,3610秒之后,应该是第11格,所以这个Task应该放在第11个slot的Set<Task>中
2. 计算这个Task的Cycle-Num,由于环形队列是3600格,这个任务是3610秒后执行,所以应该绕3610/3600=1圈之后再执行,于是Cycle-Num=1
Current Index不停的移动,每秒移动到一个新slot,遍历slot中对应的Set<Task>,每个Task看Cycle-Num是不是0:
1. 如果不是0,说明还需要多移动几圈,将Cycle-Num减1
2. 如果是0,说明马上要执行这个Task了,取出Task-Funciton执行(可以用单独的线程来执行Task),并把这个Task从Set<Task>中删除。
Netty中的工具类HashedWheelTimer的原理与这种环形的延迟队列相似。
扩展:
10w定时任务,如何高效触发超时 https://mp.weixin.qq.com/s?__biz=MjM5ODYxMDA5OQ==&mid=2651959957&idx=1&sn=a82bb7e8203b20b2a0cb5fc95b7936a5&chksm=bd2d07498a5a8e5f9f8e7b5aeaa5bd8585a0ee4bf470956e7fd0a2b36d132eb46553265f4eaf&scene=21#wechat_redirect
二 分库分表相关问题
1 面试题
为什么要分库分表(设计高并发系统的时候,数据库层面该如何设计)?用过哪些分库分表中间件?不同的分库分表中间件都有什么优点和缺点?你们具体是如何对数据库如何进行垂直拆分或水平拆分的?
2 考察点
其实这块肯定是扯到高并发了,因为分库分表一定是为了支撑高并发、数据量大两个问题的。而且现在说实话,尤其是互联网类的公司面试,基本上都会来这么一下,分库分表如此普遍的技术问题,不问实在是不行,而如果你不知道那也实在是说不过去!
3 为什么要分库分表
分库分表是两种不同的处理方式,可能是光分库不分表,也可能是光分表不分库,也可能同时分库并且分表。
例如如下场景:
(1)假如我们现在是一个小创业公司(或者是一个 BAT 公司刚兴起的一个新部门),现在注册用户就 20 万,每天活跃用户就 1 万,每天单表数据量就 1000,然后高峰期每秒钟并发请求最多就 10。这种情况单表数据量不大,并且并发量也很低,因此就不需要考虑分库分表,防止带来代码上的复杂性。
(2)如果业务发展迅猛,过了几个月,注册用户数达到了 2000 万!每天活跃用户数 100 万!每天单表数据量 10 万条!高峰期每秒最大请求达到 1000!因为每天多 10 万条数据,一个月就多 300 万条数据,现在咱们单表已经几百万数据了,马上就破千万了,但是勉强还能撑着。高峰期请求现在是 1000,咱们线上部署了几台机器,负载均衡搞了一下,数据库撑 1000QPS 也还凑合。但是这种情况下就需要考虑下分库分表了。
(3)再接下来几个月,公司用户数已经达到 1 亿,,因为此时每天活跃用户数上千万,每天单表新增数据多达 50 万,目前一个表总数据量都已经达到了两三千万了!扛不住啊!数据库磁盘容量不断消耗掉!高峰期并发达到惊人的 5000~8000
!此时系统就会撑不住而导致宕机。
因此,实际上是否需要分库分表这是跟着你的公司业务发展走的,你公司业务发展越好,用户就越多,数据量越大,请求量越大,那你单个数据库一定扛不住。
4 分表
比如你单表都几千万数据了,你确定你能扛住么?绝对不行,单表数据量太大,会极大影响你的 sql 执行的性能,到了后面你的 sql 可能就跑的很慢了。一般来说,就以我的经验来看,单表到几百万的时候,性能就会相对差一些了,你就得分表了。
分表是啥意思?就是把一个表的数据放到多个表中,然后查询的时候你就查一个表。比如按照用户 id 来分表,将一个用户的数据就放在一个表中。然后操作的时候你对一个用户就操作那个表就好了。这样可以控制每个表的数据量在可控的范围内,比如每个表就固定在 200 万以内。
5 分库
分库是啥意思?就是你一个库一般我们经验而言,最多支撑到并发 2000,一定要扩容了,而且一个健康的单库并发值你最好保持在每秒 1000 左右,不要太大。那么你可以将一个库的数据拆分到多个库中,访问的时候就访问一个库好了。
这就是所谓的分库分表,为啥要分库分表?你明白了吧。
6 常用中间件
Sharding-jdbc
当当开源的,属于 client 层方案,目前已经更名为 ShardingSphere
(后文所提到的 Sharding-jdbc
,等同于 ShardingSphere
)。确实之前用的还比较多一些,因为 SQL 语法支持也比较多,没有太多限制,而且截至 2019.4,已经推出到了 4.0.0-RC1
版本,支持分库分表、读写分离、分布式 id 生成、柔性事务(最大努力送达型事务、TCC 事务)。而且确实之前使用的公司会比较多一些(这个在官网有登记使用的公司,可以看到从 2017 年一直到现在,是有不少公司在用的),目前社区也还一直在开发和维护,还算是比较活跃,个人认为算是一个现在也可以选择的方案。
Mycat
基于 Cobar 改造的,属于 proxy 层方案,支持的功能非常完善,而且目前应该是非常火的而且不断流行的数据库中间件,社区很活跃,也有一些公司开始在用了。但是确实相比于 Sharding jdbc 来说,年轻一些,经历的锤炼少一些。
总结:
综上,现在其实建议考量的,就是 Sharding-jdbc 和 Mycat,这两个都可以去考虑使用。
Sharding-jdbc 这种 client 层方案的优点在于不用部署,运维成本低,不需要代理层的二次转发请求,性能很高,但是如果遇到升级啥的需要各个系统都重新升级版本再发布,各个系统都需要耦合 Sharding-jdbc 的依赖;
Mycat 这种 proxy 层方案的缺点在于需要单独部署,自己运维一套中间件,运维成本高,但是好处在于对于各个项目是透明的,如果遇到升级之类的都是自己中间件那里搞就行了。
通常来说,这两个方案其实都可以选用,但是我个人建议中小型公司选用 Sharding-jdbc,client 层方案轻便,而且维护成本低,不需要额外增派人手,而且中小型公司系统复杂度会低一些,项目也没那么多;但是中大型公司最好还是选用 Mycat 这类 proxy 层方案,因为可能大公司系统和项目非常多,团队很大,人员充足,那么最好是专门弄个人来研究和维护 Mycat,然后大量项目直接透明使用即可。
7 如何对数据库进行垂直拆分或水平拆分
水平拆分的意思,就是把一个表的数据给弄到多个库的多个表里去,但是每个库的表结构都一样,只不过每个库表放的数据是不同的,所有库表的数据加起来就是全部数据。水平拆分的意义,就是将数据均匀放更多的库里,然后用多个库来扛更高的并发,并且可以用多个库的存储容量来进行扩容。
垂直拆分的意思,就是把一个有很多字段的表给拆分成多个表,或者是多个库上去。每个库表的结构都不一样,每个库表都包含部分字段。一般来说,会将较少的访问频率很高的字段放到一个表里去,然后将较多的访问频率很低的字段放到另外一个表里去。因为数据库是有缓存的,你访问频率高的行字段越少,就可以在缓存里缓存更多的行,性能就越好。这个一般在表层面做的较多一些。
这个其实挺常见的,不一定我说,大家很多同学可能自己都做过,把一个大表拆开,订单表、订单支付表、订单商品表。
还有表层面的拆分,就是分表,将一个表变成 N 个表,就是让每个表的数据量控制在一定范围内,保证 SQL 的性能。否则单表数据量越大,SQL 性能就越差。一般是 200 万行左右,不要太多,但是也得看具体你怎么操作,也可能是 500 万,或者是 100 万。你的SQL越复杂,就最好让单表行数越少。
好了,无论分库还是分表,上面说的那些数据库中间件都是可以支持的。就是基本上那些中间件可以做到你分库分表之后,中间件可以根据你指定的某个字段值,比如说 userid,自动路由到对应的库上去,然后再自动路由到对应的表里去。
你就得考虑一下,你的项目里该如何分库分表?
一般来说,垂直拆分,你可以在表层面来做,对一些字段特别多的表做一下拆分;
水平拆分,你可以说是并发承载不了,或者是数据量太大,容量承载不了,你给拆了,按什么字段来拆,你自己想好;
分表,你考虑一下,你如果哪怕是拆到每个库里去,并发和容量都 ok 了,但是每个库的表还是太大了,那么你就分表,将这个表分开,保证每个表的数据量并不是很大。
而且这儿还有两种分库分表的方式:
(1)一种是按照 range 来分,就是每个库一段连续的数据,这个一般是按比如时间范围来的,但是这种一般较少用,因为很容易产生热点问题,大量的流量都打在最新的数据上了。
(2)按照某个字段 hash 一下均匀分散,这个较为常用。
range 来分,好处在于说,扩容的时候很简单,因为你只要预备好,给每个月都准备一个库就可以了,到了一个新的月份的时候,自然而然,就会写新的库了;缺点,但是大部分的请求,都是访问最新的数据。实际生产用 range,要看场景。
hash 分发,好处在于说,可以平均分配每个库的数据量和请求压力;坏处在于说扩容起来比较麻烦,会有一个数据迁移的过程,之前的数据需要重新计算 hash 值重新分配到不同的库或表。
参考:https://github.com/yuejuntao/advanced-java/blob/master/docs/high-concurrency/database-shard.md
三 如何设计让系统从未分库分表动态切换到分库分表上
1 面试题
现在有一个未分库分表的系统,未来要分库分表,如何设计才可以让系统从未分库分表动态切换到分库分表上?
2 考察点
现在已经明白为啥要分库分表了,你也知道常用的分库分表中间件了,你也设计好你们如何分库分表的方案了(水平拆分、垂直拆分、分表),那问题来了,你接下来该怎么把你那个单库单表的系统给迁移到分库分表上去?
所以这都是一环扣一环的,就是看你有没有全流程经历过这个过程。
3 解析
1 停机迁移方案
最简单的方案,在凌晨 12 点开始运维,网站或者 app 挂个公告,说 0 点到早上 6 点进行运维,无法访问。接着到 0 点停机,系统停掉。
由于没有新的流量写入了,因此此时老的单库单表数据库的数据不会发生变化。此时可以通过开发的数据传输工具,然后将单库单表的数据读出来,写到分库分表里面去。
导数完了之后,修改系统的数据库连接配置,包括可能代码和 SQL 也许有修改,那你就用最新的代码,然后直接启动连到新的分库分表上去。
如图所示,具体步骤如下:
(1)系统停机,不允许外界访问
(2)通过后台程序将老的单表单库中的数据按照按照分库分表的规则导入到新的分库分表中。
(3)修改系统配置及分库分享相关SQL,使系统连接到新的数据库上,并且之后新的数据都写入到新的分库分表中。
(4)启动系统,运行外界访问。
2 双写迁移方案
在停机迁移方案中,需要使系统停止运行一段时间,这有时候对线上是无法接受的,因此可以采用双写迁移方案,不需要停机。
简单来说,就是在线上系统里面,之前所有写库的地方,增删改操作,除了对老库增删改,都加上对新库的增删改,这就是所谓的双写,同时写俩库,老库和新库。此时查询仍然从老的库中查询。
然后系统部署之后,新库数据差太远,用后台程序跑起来读老库数据写入到新库,写的时候要根据 modified_time 这类字段判断这条数据最后修改的时间,除非是读出来的数据在新库里没有,或者是比新库的数据新才会写。简单来说,就是不允许用老数据覆盖新数据。
导完一轮之后,有可能数据还是存在不一致,那么就程序自动做一轮校验,比对新老库每个表的每条数据,接着如果有不一样的,就针对那些不一样的,从老库读数据再次写。反复循环,直到两个库每个表的数据都完全一致为止。
接着当数据完全一致了,就切换读数据从新库中读取,同时断开老数据库连接。
参考:https://github.com/yuejuntao/advanced-java/blob/master/docs/high-concurrency/database-shard-method.md
四 分库分表后,id主键如何处理
1 面试题
分库分表之后,id 主键如何处理?
2 考察点
其实这是分库分表之后你必然要面对的一个问题,就是 id 咋生成?
因为要是分成多个表之后,每个表都是从 1 开始累加,那肯定不对啊,需要一个全局唯一的 id 来支持。所以这都是你实际生产环境中必须考虑的问题。
3 解析
1 基于数据库的实现方案
(1)数据库自增id
这个就是说你的系统里每次得到一个 id,都是往一个库的一个表里插入一条没什么业务含义的数据,然后获取一个数据库自增的一个 id。拿到这个 id 之后再往对应的分库分表里去写入。
例如名为table的表结构如下:
id field
35 a
每一次生成id的时候,都访问数据库,执行如下语句:
begin; REPLACE INTO table ( feild ) VALUES ( 'a' ); SELECT LAST_INSERT_ID(); commit;
REPLACE INTO 的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据。
这样一来,每次都可以得到一个递增的ID。
这个方案的好处就是方便简单;缺点就是单库生成自增 id,要是高并发的话,就会有瓶颈的;
如果改进一下,那么就专门开一个服务出来,这个服务每次就拿到当前 id 最大值,然后自己递增几个 id,一次性返回一批 id,然后再把当前最大 id 值修改成递增几个 id 之后的一个值;但是无论如何都是基于单个数据库。
适合的场景:你分库分表无非两个原因:(1)单库并发太高,(2)单库数据量太大;除非是你并发不高,但是数据量太大导致的分库分表扩容,你可以用这个方案,因为可能每秒最高并发最多就几百,那么就走单独的一个库和表生成自增主键即可。
(2)设置数据库sequence或者表的自增字段步长
由于使用数据库自增id只能基于单个数据库和表,因此在并发量高的情况下并不适用。此时可以通过设置数据库sequence(Oracle)或者表的自增字段来进行水平伸缩。
例如:搭建8个数据库节点,每个数据库中使用一个表来产生id。其中每个数据库节点使用一个sequence功能来产生id,每个sequence的起始ID不同,并且依次递增,步长都是8:
因此此时既能将单个库生成自增id扩展到多个库,增加了并发量。
适合的场景:在用户防止产生的 ID 重复时,这种方案实现起来比较简单,也能达到性能目标。但是服务节点固定,步长也固定,将来如果还要增加服务节点,就不好搞了。
2 UUID
好处就是本地生成,不要基于数据库来了;
不好之处就是,UUID 太长了、占用空间大,作为主键性能太差了;
更重要的是,UUID 不具有有序性,会导致 B+ 树索引在写的时候有过多的随机写操作(连续的 ID 可以产生部分顺序写),还有,由于在写的时候不能产生有顺序的 append 操作,而需要进行 insert 操作,将会读取整个 B+ 树节点到内存,在插入这条记录后会将整个节点写回磁盘,这种操作在记录占用空间比较大的情况下,性能下降明显。
例如如下B+树索引:
如果我们的ID按递增的顺序来插入,比如陆续插入8,9,10,新的ID都只会插入到最后一个节点当中。当最后一个节点满了,会裂变出新的节点。这样的插入是性能比较高的插入,因为这样节点的分裂次数最少,而且充分利用了每一个节点的空间。
但是,如果我们的插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能。
适合的场景:如果你是要随机生成个什么文件名、编号之类的,你可以用 UUID,但是作为主键是不能用 UUID 的。
UUID.randomUUID().toString().replace(“-”, “”) -> sfsdf23423rr234sfdaf
3 获取系统当前时间
这个就是获取当前时间即可,但是问题是,并发很高的时候,比如一秒并发几千,会有重复的情况,这个是肯定不合适的。基本就不用考虑了。
适合的场景:一般如果用这个方案,是将当前时间跟很多其他的业务字段拼接起来,作为一个 id,如果业务上你觉得可以接受,那么也是可以的。你可以将别的业务字段值跟当前时间拼接起来,组成一个全局唯一的编号。
4 snowflake算法
snowflake 算法是 twitter 开源的分布式 id 生成算法,采用 Scala 语言实现,是把一个 64 位的 long 型的 id分成四部分:1 个 bit 是不用的,用其中的 41 bit 作为毫秒数,用 10 bit 作为工作机器 id,12 bit 作为序列号。
(1)1 bit:不用,为啥呢?因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0。
(2)41 bit:表示的是时间戳,单位是毫秒。41 bit 可以表示的数字多达 2^41 - 1
,也就是可以标识 2^41 - 1
个毫秒值,换算成年就是表示69年的时间。
(3)10 bit:记录工作机器 id,代表的是这个服务最多可以部署在 2^10台机器上哪,也就是1024台机器。但是 10 bit 里 5 个 bit 代表机房 id,5 个 bit 代表机器 id。意思就是最多代表 2^5
个机房(32个机房),每个机房里可以代表 2^5
个机器(32台机器)。
(4)12 bit:这个是用来记录同一个毫秒内产生的不同 id,12 bit 可以代表的最大正整数是 2^12 - 1 = 4096
,也就是说可以用这个 12 bit 代表的数字来区分同一个毫秒内的 4096 个不同的 id。
public class IdWorker { //工作节点id(0~31) private long workerId; //数据中心id(0~31) private long datacenterId; //毫秒内序列(0~4095) private long sequence; //构造函数 public IdWorker(long workerId, long datacenterId, long sequence) { // sanity check for workerId // 这儿不就检查了一下,要求就是你传递进来的机房id和机器id不能超过32,不能小于0 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)); } System.out.printf( "worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d", timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId); this.workerId = workerId; this.datacenterId = datacenterId; this.sequence = sequence; } //初始时间戳 private long twepoch = 1288834974657L; //机器id所占位数 private long workerIdBits = 5L; //数据标识id所占位数 private long datacenterIdBits = 5L; // 这个是二进制运算,就是 5 bit最多只能有31个数字,也就是说机器id最多只能是32以内 private long maxWorkerId = -1L ^ (-1L << workerIdBits); // 这个是一个意思,就是 5 bit最多只能有31个数字,机房id最多只能是32以内 private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); //序列在id中所占的位数 private long sequenceBits = 12L; //机器id的偏移量(12) private long workerIdShift = sequenceBits; //机房id偏移量(12+5) private long datacenterIdShift = sequenceBits + workerIdBits; //时间戳偏移量(5+5+12) private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; //生成的序列的掩码,治理为4095(0b111111111111=0xfff=4095) private long sequenceMask = -1L ^ (-1L << sequenceBits); //上次生成id的时间戳 private long lastTimestamp = -1L; public long getWorkerId() { return workerId; } public long getDatacenterId() { return datacenterId; } public long getTimestamp() { return System.currentTimeMillis(); } //获取下一个id(用同步锁保证线程安全) public synchronized long nextId() { // 这儿就是获取当前时间戳,单位是毫秒 long timestamp = timeGen(); //如果当前时间小于上一次id生成的时间戳,则说明系统时钟回退过,这个时候抛出异常 if (timestamp < lastTimestamp) { System.err.printf("clock is moving backwards. Rejecting requests until %d.", lastTimestamp); throw new RuntimeException(String.format( "Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp)); } if (lastTimestamp == timestamp) { //如果是同一时间生成的,则进入毫秒内序列 //这个意思是说一个毫秒内最多只能有4096个数字 // 无论你传递多少进来,这个位运算保证始终就是在4096这个范围内,避免你自己传递个sequence超过了4096这个范围 sequence = (sequence + 1) & sequenceMask; //sequence等于0说明毫秒内序列已经增长到最大值 if (sequence == 0) { //阻塞到下一个毫秒,获取新的时间戳 timestamp = tilNextMillis(lastTimestamp); } } else { sequence = 0; } // 这儿记录一下最近一次生成id的时间戳,单位是毫秒 lastTimestamp = timestamp; // 这儿就是将时间戳左移,放到 41 bit那儿; // 将机房 id左移放到 5 bit那儿; // 将机器id左移放到5 bit那儿;将序号放最后12 bit; // 最后拼接起来成一个 64 bit的二进制数字,转换成 10 进制就是个 long 型 return ((timestamp - twepoch) << timestampLeftShift) | (datacenterId << datacenterIdShift) | (workerId << workerIdShift) | sequence; } //阻塞到下一个毫秒,知道获取到新的时间戳 private long tilNextMillis(long lastTimestamp) { long timestamp = timeGen(); while (timestamp <= lastTimestamp) { timestamp = timeGen(); } return timestamp; } private long timeGen() { return System.currentTimeMillis(); } // ---------------测试--------------- public static void main(String[] args) { IdWorker worker = new IdWorker(1, 1, 1); for (int i = 0; i < 30; i++) { System.out.println(worker.nextId()); } } }
说明:
1.获得单一机器的下一个序列号,使用Synchronized控制并发,而非CAS的方式,是因为CAS不适合并发量非常高的场景。
2.如果当前毫秒在一台机器的序列号已经增长到最大值4095,则使用while循环等待直到下一毫秒。
3.如果当前时间小于记录的上一个毫秒值,则说明这台机器的时间回拨了,抛出异常。但如果这台机器的系统时间在启动之前回拨过,那么有可能出现ID重复的危险。
SnowFlake算法的优点:
1.生成ID时不依赖于DB,完全在内存生成,高性能高可用。
2.ID呈趋势递增,后续插入索引树的时候性能较好。
SnowFlake算法的缺点:
依赖于系统时钟的一致性。如果某台机器的系统时钟回拨,有可能造成ID冲突,或者ID乱序。
参考:https://mp.weixin.qq.com/s/JiyZbaAujBtD8F4ddc-uAw
五 count(1)比count(*)效率高吗
有 Where 条件的 count,会根据扫码结果count 一下所有的行数,其性能更依赖于你的 Where 条件,所以文章我们仅针对没有 Where 的情况进行说明。
MyISAM 引擎会把一个表的总行数记录了下来,所以在执行 count(*) 的时候会直接返回数量,执行效率很高。
在 MySQL 5.5 以后默认引擎切换为 InnoDB,InnoDB 因为增加了版本控制(MVCC)的原因,同时有多个事务访问数据并且有更新操作的时候,每个事务需要维护自己的可见性,那么每个事务查询到的行数也是不同的,所以不能缓存具体的行数,他每次都需要 count 一下所有的行数。那么 count(1) 和 count(*)有区别么?
Returns a count of the number of non-NULL values of expr in the rows retrieved by a SELECT statement. The result is a BIGINT value.
大致的解释是返回 SELECT 语句检索的行中 expr 的非 NULL 值的计数,到这里我们就明白了,首先它是一个聚合函数,然后对 SELECT 的结果集进行计数,但是需要参数不为 NULL。那么我们继续阅读官网的内容:
COUNT(*) is somewhat different in that it returns a count of the number of rows retrieved, whether or not they contain NULL values.
大致的内容是说,count(*) 不同,他不关心这个返回值是否为空都会计算他的count,因为 count(1) 中的 1 是恒真表达式,那么 count(*) 还是 count(1) 都是对所有的结果集进行 count,所以他们本质上没有什么区别。
到这里我们明白了 count(*) 和 count(1) 本质上面其实是一样的,那么 count(column) 又是怎么回事呢?
count(column) 也是会遍历整张表,但是不同的是它会拿到 column 的值以后判断是否为空,然后再进行累加,那么如果针对主键需要解析内容,如果是二级所以需要再次根据主键获取内容,又是一次 IO 操作,所以 count(column) 的性能肯定不如前两者喽,如果按照效率比较的话:
count(*)=count(1)>count(primary key)>count(column)
既然 count(*) 在查询上依赖于所有的数据集,是不是我们在设计上也需要尽量的规避全量 count 呢?通常情况我们针对可预见的 count 查询会做适当的缓存,可以是 Redis,也可以是独立的 MySQL count 表,当然无论是哪种方式我们都需要考虑一致性的问题。