分布式ID
1.分布式ID应用场景
关键字:全局唯一ID
某一张表的数据量很大,影响读写效率,为了解决这样的问题,我们可以将一个大的数据库拆分成多个小的数据库(分库分表)(主从、读写分离),但是随着数据流的不断增多,分到每一个数据库的数据也会越来越多,此时响应的速度就达不到要求了。
此时我们可以将每个数据库拆分成多个表(例如10亿数据,拆分成100个表,这样每个表的数据就只有1000万)。经过这样的拆分,每个表的唯一的行记录,总是需要一个唯一标识的(也就是主键),此时,该主键若是用数据库的自增ID,显然是不能满足要求的:表A中主键有1、2、3,表B中也会出现主键1、2、3的数据,这样一来,就无法确定行数据的唯一性。
基于上述背景,一个能够生成全局唯一ID的系统是非常必要的,表A中数据的ID是1、2、3,表B中数据的ID是4、5、6。这样的全局唯一的ID就称之为分布式ID
2.分布式ID的条件
- 全局唯一
- 高性能
- 生成ID请求的响应速度必须要快。
- 高可用
- 尽量的让我们的分布式ID都能用。
- 易用
- 秉承着使用难度低的原则,其他人上手即用。
- 趋势递增
3.分布式ID的生成方案
1.UUID方案
关键字:无序,无业务含义,字符串,本地生成
public class TestUUID{
public static void main(String[] args){
String uuid=UUID.randomUUID().toString();
}
}
UUID是生成一个完全随机的无序字符串(甚至是全球唯一)完全没有具体的含义。但其实,UUID并不适用于实际的需求,
- 生成订单号时,订单号是要符合一定的规律的(地区、时间、厂家),此时若使用UUID来当作订单号,意义不大。
- 作为数据库业务主键时候,UUID过长且是一个字符串,导致存储性能差,查询时间长,无意义的字符串不具备趋势递增的特性,且UUID的无序性,可能导致数据位置的变动。导致不推荐,为什么是不推荐?因为UUID作为数据库业务主键的话,是本地生成的,无网络消耗
- Mysql官方建议:主键越短越好
2.基于数据库自增ID
关键字:基于独立的一个数据库,访问量突增易宕机
这里的数据库自增ID不是基于每张表的,而是基于一个独立的数据库,用这个数据库(记作A)来生成ID。当某个数据库的某个表新增了一条数据需要ID的时候,请求数据库A来得到一个ID,以此往复。
当访问量很大的时候,有可能会出现数据库A宕机的情景,这样会导致其他数据库无法运行。
3.基于数据库集群模式
关键字:解决DB单点故障,不易扩展
该模式是基于单点数据库作为自增ID时候出现的单点故障进行优化而产生的模式。
我们将生成ID的mysql数据库做成主从集群的模式或者双主模式。两个Mysql的实例都能单独的生成自增ID,那么如何保证两个数据库的ID不会重复呢?
- 根据数据库的数量设置起始值和自增的步长。数据库A设置起始值为1,步长为2;数据库B设置起始值为2,步长为2。
- 对于已经投入使用的数据库,还需要再新增一个ID数据库时,无论怎么设置起始值都会导致重复,所以,我们在最开始设计的时候,数据库之间的ID起始值间距设置大一些。
4.基于数据库号段模式
关键字:减少数据库访问的次数
该模式也是当下主流的分布式ID生成模式。从数据库批量的获取自增ID,每次从数据库取出一个号段的范围,例如:某个服务需要生成ID,先从数据库里拿上100或1000个ID回来,具体使用从拿回来的ID里分配。该模式生成ID的时候不会强依赖于数据库,不会去频繁的访问ID数据库。
在ID数据库中的一个表存上几个字段
-
业务类型:用户/订单………
-
maxID:最大的号段ID是多少。
-
step:步长,号段的长度(该号段一共多少个)
-
version:乐观锁的方式避免号段重复请求
一条语句请求的maxID是1000,step也是1000,那么1-1000的数据就会返回回去。至于是否用得完,业务不关心
5.Redis模式
关键字:redis递增命令,持久化策略(数据重复,重启恢复时间长)
利用Redis的递增命令,实现key-value中value的递增,并且redis的单线程的原子操作,可以保证redis的唯一性。
需要注意redis持久化的问题:RDB、AOF
- 在持久化策略为RDB的情况下,可能会导致数据重复。例如:请求增了100个id,在存到51个的时候redis刚好触发快照,也就是当时只持久化了50条数据,在下一次快照之前redis宕机了,就会导致剩余的50个id没有被持久化,在启动的时候又会从51开始,导致id重复。
- 在持久化策略为AOF的情况下,可能会导致Redis重启恢复的时候消耗过长,这是由于递增命令的特殊性(需要计算)
6.雪花算法模式
关键字:减少网络请求的开销
一大长串的数字,各个位数有各自特定的意义。
只要机器确定了之后,是不需要单独搭建一个分布式ID的数据库的。减少了网络请求的开销
分布式锁
1.分布式锁的应用场景
关键字:超卖,多个请求进入同一个业务逻辑
- 电商业务中的下单:多个人抢固定数量的商品。
在真实的项目中,为了实现高可用,一般会构建多个Server服务器,当用户发起请求的时候,用户的请求会同时进入到这两个Server当中(以下记作A、B)。如果商品的库存只有一份商品,那么一个商品就会产生两次扣减请求,此时商品的数量为-1,这就是常说的超卖
- 在打车的业务场景中,多个司机抢同一个订单的情况下,需要对订单的状态加以控制(不会出现司机C与司机D同时抢到了同一个订单的情况)
2.预防超卖业务实现
1.架构
关键词:负载均衡,中台,共有服务
- 服务A,一般是以api-xxx开头。、
- 接受用户的请求。具体的实现是通过服务B实现
- 服务B,一般是以service-xxx开头。
- 实现具体的业务
当服务B是以集群的形式搭建的时候,服务A也可以同时起到一个负载均衡的效果
很多公司会把底层的服务B称作为基础能力,抽象成中台(业务中台、数据中台),也叫做共用业务,谁要用,谁就来调用
2.单体服务超卖问题
关键词:单体服务,intern()方法,synchronized
- String的intern()方法:返回字符串池中的一个相同内容的实例引用。如果字符串池中没有相同内容的字符串,就把当前字符串内容放到池中并返回这个字符串在池中的地址。
String s1="xxx";//从字符串常量池中创建xxx,并且返回地址
String s2=new String("xxx")//从堆中创建"xxx"并且返回地址
在synchronized加锁的时候,希望锁住的对象是同一个,刚好符合String常量池的概念,只要创建String对象时去常量池找,就可以保证对象的唯一性。所以我们通常使用String的intern()方法。
- 由于synchronized本身的特性,这样做很有可能会影响服务整体的速度,该方法也是在单体应用的情况下才会存在。
3.集群下解决超卖问题
关键字:集群,锁互不相认,分布式锁,第三方加锁
在大多数场景中,是不会出现上述的单体应用去处理服务的情况的,因为一旦单体应用服务挂掉了,可能会导致整个服务的瘫痪,所以应用基本上都是集群部署的。
在集群模式中,我们有两个服务,但是仍然会出现超卖的问题,归根结底的原因是:这两个服务中的锁互不认识。为了解决这一问题,就出现了分布式锁
所谓分布式锁,其实就是第三方加锁,当服务A收到请求之后,先去第三方加锁,拿到锁之后,开始执行业务逻辑,执行完毕之后释放锁,至此服务B才可以尝试去获取锁。
3.分布式锁的方案
1.mysql方案
关键字:主键,唯一索引,磁盘IO
当服务A执行业务逻辑的时候,往数据库插入一条主键为1的数据或者唯一索引为一的数据。这样在服务B想要去执行逻辑的时候,由于互斥性无法插入数据,也就达到了锁的效果。当服务A执行完毕后删除该记录,也就是释放锁,至此服务B才可以继续执行。
这样加锁的弊端在于频繁的对数据库进行操作,可能会导致数据库宕机。其实mysql是基于磁盘的IO进行的
public class MysqlLock implements Lock {
@Autowired
private TblOrderLockDao mapper;
private ThreadLocal<TblOrderLock> orderLockThreadLocal ;
@Override
public void lock() {
// 1、尝试加锁
if(tryLock()) {
System.out.println("尝试加锁");
return;
}
// 2.休眠
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 3.递归再次调用
lock();
}
/**
* 非阻塞式加锁,成功,就成功,失败就失败。直接返回
*/
@Override
public boolean tryLock() {
try {
TblOrderLock tblOrderLock = orderLockThreadLocal.get();
mapper.insertSelective(tblOrderLock);
System.out.println("加锁对象:"+orderLockThreadLocal.get());
return true;
}catch (Exception e) {
return false;
}
}
@Override
public void unlock() {
mapper.deleteByPrimaryKey(orderLockThreadLocal.get().getOrderId());
System.out.println("解锁对象:"+orderLockThreadLocal.get());
orderLockThreadLocal.remove();
}
@Override
public void lockInterruptibly() throws InterruptedException {
// TODO Auto-generated method stub
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
// TODO Auto-generated method stub
return false;
}
@Override
public Condition newCondition() {
// TODO Auto-generated method stub
return null;
}
}
2.Redis单节点方案
关键字:内存IO,setnx,死锁,超时时间(原子操作),WatchDog
利用Redis中的SETNX命令
加锁:setnx [key] [value]
将key的值设置为value,当且仅当key不存在。若key存在,则不会进行任何操作。
释放锁:del key
2.1 Redis分布式锁的死锁问题以及解决方案
有一种特殊的情况,在服务A获取锁之后,执行业务逻辑的途中(释放锁之前),服务A挂掉了,这样的话key就会一直存在于业务当中,导致后续的服务B一直无法获取到锁。这就是Redis分布式锁的死锁情况。
解决办法也很简单,就是给key设置一个过期时间,且必须和setnx命令一同设置(不可单独设置)。保证操作的原子性
在setnx操作的时候设置过期时间,又可能会由于过期时间过早导致新的问题:
- 过期时间过早服务A总执行时间为14秒,但是key的超时时间为10秒,此时由于key已经不存在了,导致服务B获取到了锁。
- 解决方案:
- 锁续期:当key又即将释放的时候,服务A还未执行完,就对key进行一个续期操作(WatchDog)。
- 解决方案:
- 释放了别人的锁:如上B获取锁之后执行,服务A执行完毕后删除的锁的key是服务B创建的,以此往复。
- 解决方案
- setnx key value的时候value保证特有性,当删除锁的时候去判断value是否是自己特有的value,如果是再去删除。
- 解决方案
2.2 利用分段锁提升Redis锁的性能
关键字:分段锁
在执行setnx操作的时候,key值进行分段操作。例如id-1,id-2,这样就可以有两把锁了。
3. Redis集群加锁
关键字:实时性,红锁,宕机不立即重启
背景:一主二从的三哨兵模式。
在集群模式下可能会出现一个问题:当服务A执行setnx命令成功之后,主Redis挂掉了,此时主节点数据还未同步给从节点,导致从节点没有服务Asetnx的key值,此时服务B就可以获取到锁去执行业务流程了。仍然会出现多个服务执行同一个方法的问题出现。该问题出现的原因:Redis节点数据的同步不是实时的。
为了解决上述的问题,可以让Redis集群之间不做数据的同步,启动多台Redis,并且他们都是独立的,于是:红锁方案诞生了
4 红锁
关键字:单数Redis服务器,超过半数
启动单数台的Redis,业务先去R1加锁,成功后去R2加锁,以此类推,直至超过了半数的Redis都加锁成功,则认为业务加锁成功,所以一定要是单数台的Redis。
如果其中某一台服务器挂掉了,没关系,只要加锁成功的Redis数量超过了启动时Redis数量的一半,即可加锁超过。
4.1 红锁问题
有一种特殊情况,假设Redis部署了三台R1,R2,R3。
业务请求A请求进行加锁,R1加锁成功,R2也加锁成功,业务A拿到做,执行业务逻辑。
此时R2服务器挂掉了,并且立即重启
业务请求B请求进行加锁,R1中已经存在key,在R1中加锁失败,R2中由于重启了,没有业务A存留下来的key,加锁成功,R3加锁成功,这样业务B也会拿到锁,去执行业务逻辑。最终导致业务出现错误。
这是一种很特殊的情况,解决办法就是部署Redis的时候,要求宕机后不立即进行重启即可
5. Redis终极问题
关键字:FullGC
服务A有JVM,服务B也有JVM。
当服务A中拿到锁后正在执行业务逻辑的时候,服务A的JVM触发了FullGC,导致服务A处于停滞状态,此时服务A的WatchDog自然就不会去Redis进行续期操作。
此时服务B登场了,拿到了锁执行了业务逻辑,导致业务逻辑的错误。
5.1 zookeeper解决Redis终极问题
关键字:顺序节点,临时节点
利用ZK做锁,需要用到其两个特性
- **顺序节点:**第一次加锁的节点是1号节点,第二次来加锁的节点是2号节点,依次递增。
- **临时节点:**若程序与ZK构建了网络连接,那么节点就会一直存在,若网络连接断开,则不会存在。
客户端A创建节点my_lock , 创建节点成功xxx-00001,然后判断当前节点是不是my_lock下的第一个节点,是的,所以加锁成功
客户端B创建节点my_lock ,因为是有序的,所以节点是xxx-00002,然后判断当前节点是不是my_lock下的第一个节点,显然不是,对当前节点的上一个节点(xxx-00001)添加一个监听器
客户端A使用完后,释放锁,删除节点xxx-00001,ZK会负责通知监听这个节点的监听器,也就是客户端B的监听器说锁释放了。
客户端B开始加锁,重复上面的操作