当前位置:首页 > 游戏技术 > 正文

分布式id生成器&分布式锁介绍

分布式id生成器在分布式场景中,唯一id的生成算比较重要。而通常在高并发场景中,需要类似MySQL自增id一样不断增长且又不会重复的id,即MySql的主键id。比如,在电商618或者双11搞活动的时候,一般在0点开始,会有千万到亿级的订单量写入,每秒大概需要处理10万加的订单。而在将订单插入数据库...

分布式id生成器在分布式场景中,唯一id的生成算比较重要。而通常在高并发场景中,需要类似MySQL自增id一样不断增长且又不会重复的id,即MySql的主键id。比如,在电商618或者双11搞活动的时......

分布式id生成器

在分布式场景中,唯一id的生成算比较重要。

而通常在高并发场景中,需要类似MySQL自增id一样不断增长且又不会重复的id,即MySql的主键id。

比如,在电商618或者双11搞活动的时候,一般在0点开始,会有千万到亿级的订单量写入,每秒大概需要处理10万加的订单。

而在将订单插入数据库之前,我们在业务上需要给订单一个唯一的id,即利用idMaker生存唯一的订单号,再插入数据库内。如果生成的id是随机且没有含义的纯数字的话,在大订单量的情况下,对数据库进行增删改查时就不能起到提高效率的作用。所以此id应该应该包含一些时间信息,机器信息等,这样即使后端的系统对消息进行了分库分表,也能够以时间顺序对这些消息进行排序了。

比较典型的就是推特的【雪花算法】了,在以上场景下可以算是最优解,原理如图:

首先确定的是,id数值长度是64位,int64类型,除去开头的符号位unused,其它可以分为四个部分:

41位来表示收到请求时的时间戳,单位为毫秒

5位表示数据中心的id

5位表求机器的实例id

12位为循环自增id,到达1111,1111,1111后归就会0

以上机制原理生成的id,可以支持一台机器在一毫秒内能够产生4096条消息。也就是一秒共409.6w条消息。单单从值域上来讲是完全够用。

数据中心id加上实例id共有10位,每个数据中心可以部署32台实例,搭建32个数据中心,所以可以一共部署1024台实例。

而41位的时间戳(毫秒为单位)能够使用69年。

worker_id如何分配

timestamp(时间戳),datacenter_id(数据中心),worker_id(机器ID)和sequence_id(序号)这四个字段中,timestamp和sequence_id是由程序在运行期生成的。但datacenter_id和worker_id需要在部署阶段就要能够获取得到,并且一旦程序启动之后,就是不可更改的了,因为如果可以随意更改,可能会造成最终生成的id有冲突。

不过一般不同数据中心的机器,会提供对应的获取数据中心id的API,因此datacenter_id我们可以在部署阶段轻松地获取到。而worker_id是我们逻辑上给机器分配的一个id,比较简单的做法就是由能够提供这种自增id功能的工具来支持,比如MySql:

mysqlinsertintoa(ip)values("10.115.4.66");QueryOK,1rowaffected(0.00sec)mysqlselectlast_insert_id();+------------------+|last_insert_id()|+------------------+|2|+------------------+1rowinset(0.00sec)复制代码

从MySql中获取到worker_id之后,就把这个worker_id直接持久化到本地,以避免每次上线时都需要获取新的worker_id。让单实例的worker_id可以始终保持不变。

但是,使用MySQL的话,相当于给id生成服务增加了一个外部依赖。当然依赖越多,服务的运维成本就会增加。

考虑到集群中即使有单个id生成服务的实例挂了,也就是损失一段时间的一部分id,所以我们也可以更简单暴力一些,把worker_id直接写在worker的配置中,上线时,由部署脚本完成worker_id字段替换即可。

开源示例:标准雪花算法

/bwmarrin/snowflake是一个相对轻量级的snowflake的Go实现。其文档对各位使用的定义如下图所示:

此库和标准的snowflake实现方式全完一致,使用也比较简单,直接上示例代码:

packagemainimport("fmt""/bwmarrin/snowflake")funcmain(){node,err:=(1)iferr!=nil{println(())(1)}fori:=0;i20;i++{id:=()("Int64ID:%d\n",id)("StringID:%s\n",id)("IDTime:%d\n",())("IDNode:%d\n",())("IDStep:%d\n",())("-------------------")}}复制代码
分布式锁

单机程序并发或并行修改全局共享变量时,需要对修改行为加锁。因为如果不加锁,多个协程序就会对该变量竞争,然后得到的结果就会不准确,或者说得到的结果不是我们所预期的,比如下面的例子:

packagemainfuncmain(){=0fori:=1;i1000;i++{(1)gofunc(){()count++}()}()(count)}复制代码

多次运行结果不同:

➜➜➜复制代码

预期的结果是:999

进程内加锁

而如果想要得到正确(预期)的结果,要把计数器的操作代码部分加上锁:

packagemainimport("fmt""sync")funcmain(){=0fori:=1;i1000;i++{(1)gofunc(){()()//加锁count++()//释放锁}()}()(count)}复制代码

这样能够得到正确结果:

➜复制代码
尝试加锁tryLock

在某些场景,我们往往只希望一个任务有单一的执行者,而不像计数器一样,所有的Goroutine都成功执行。后续的Goroutine在抢锁失败后,需要放弃执行,这时候就需要用到尝试加锁,即实现trylock。

尝试加锁,在加锁成功后执行后续流程,失败时不可以阻塞,而是直接返回加锁的结果。

在Go语言中可以用大小为1的Channel来模拟trylock:

packagemainimport("fmt""sync")typeMyLockstruct{lockChchanstruct{}}funcNewLock()MyLock{varmyLockMyLockmyLock=MyLock{lockCh:make(chanstruct{},1),}{}{}returnmyLock}func(l*MyLock)Lock()bool{result:=falseselect{:result=truedefault://这里去掉就会阻塞,直到获取到锁}returnresult}func(l*MyLock)Unlock(){{}{}}funcmain(){:=NewLock()fori:=0;i10;i++{(1)gofunc(){()if!(){("getlockfailed")return}count++("count=",count)()}()}()}复制代码

每个Goruntine只有获取到锁(成功执行了Lock)才会继续执行后续代码,然后在Unlock()时可以保证Lock结构体里的Channel一定是空的,所以不会阻塞也不会失败。

在单机系统中,tryLock并不是一个好选择,因为大量的Goruntine抢锁会无意义地占用cpu资源,这就是活锁,所有不建议使用这种锁。

基于Redis的setnx分布式锁

在分布式场景中,也需要“抢占”的逻辑,可以用Redis的setnx实现:

packagemainimport("/go-redis/redis""sync""time")funcsetnx(){client:=({})varlockKey="counter_lock"varcounterKey="counter"//lockresp:=(lockKey,1,*6)lockStatus,err:=()iferr!=nil||!lockStatus{println("lockfailed")return}//counter++getResp:=(counterKey)cntValue,err:=()iferr==nil||err=={cntValue++resp:=(counterKey,cntValue,0)_,err:=()iferr!=nil{println(err)}}println("currentcounteris",cntValue)//unlockdelResp:=(lockKey)unlockStatus,err:=()iferr==nilunlockStatus0{println("unlocksuccess")}else{println("unlockfailed",err)}}funcmain(){:=0;i10;i++{(1)gofunc(){()setnx()}()}()}复制代码

运行结果:

➜teris34lockfailedunlocksuccess复制代码

通过上面的代码和执行结果可以看到,远程调用setnx运行流程上和单机的troLock非常相似,如果获取锁失败,那么相关的任务逻辑就不会继续向后执行。

setnx很适合高并发场景下用来争抢一些“唯一”的资源。比如,商城秒杀的商品,在某个时间点,多个买家会对其进行下单并发争抢。这种场景我们没有办法依赖具体的时间来判断先后,因为不同设备的时间不能保证使用的是统一的时间,也就不能保证时序。

所以,我们需要依赖于这些请求到达redis节点的顺序来做正确的抢锁操作。

如果用户的网络环境比较差,是有可能抢不到的。

基于ZooKeeper分布式锁

基于ZooKeeper的锁与基于Redis的锁有点类似,不同之处在于Lock成功之前会一直阻塞,这与单机场景中的很相似。

packagemainimport("/go-zookeeper/zk""time")funcmain(){c,_,err:=([]string{"127.0.0.1"},)iferr!=nil{panic(err)}l:=(c,"/lock",())err=()iferr!=nil{panic(err)}println("locksuccess,doyourbusinesslogic")(*10)//模拟业务处理()println("unlocksuccess,finishbusinesslogic")}复制代码

其原理也是基于临时Sequence节点和watchAPI,例如我们这里使用的是/lock节点。

Lock会在该节点下的节点列表中插入自己的值,只要节点下的子节点发生变化,就会通知所有watch该节点的程序。这时候程序会检查当前节点下最小的子节点的id是否与自己的一致。如果一致,说明加锁成功了。

这种分布式的阻塞锁比较适合分布式任务调度场景,但不适合高频次持锁时间短的抢锁场景。

一般基于强一致协议的锁适用于粗粒度的加锁操作。这里的粗粒度指锁占用时间较长。我们在使用时也应思考在自己的业务场景中使用是否合适。

总结

本期主要介绍了分布式id的使用场景、分布式id如何生成的,以及分布式锁和使用。

雪花算法介绍和实现

分布式锁介绍和相关实现

最新文章