在springboot这种单例模式下,本地锁只能锁住当前线程,当其他服务的请求过来时是锁不住的,这时就需要使用分布式锁。
每个服务只放行一个请求过来,也大大减少了数据库的压力,这种方式可行吗?不行!如果每个服务的请求都是读数据库的话,每个服务都放行一个请求来构建缓存,没有太大问题,确实大大减少了数据库的压力。 但是!如果有两个请求是往数据库写数据,就会导致其他服务的内存与数据库的数据不一致性。
当第一个请求往数据库写数据,然后去替换缓存,这时由于网络原因,第二个请求过来,又更改了数据库,往缓存中写入了数据,这时第一个请求写入缓存覆盖了第二个请求写入缓存的数据,就会导致数据的不一致性。
本地锁
当请求过来时,先在方法中加上本地锁,拿到锁之后判断缓存中是否有数据,有数据直接返回释放掉锁,没有数据查询数据库。
上面的两张图,看见输出了两次查询了数据库可以可以看见,我们的本地锁实效了 没有锁住本地线程只放行一个请求去查询数据库,而是放了两个请求去查询数据库。 原因是没有保持查询数据库和刷新🔄缓存的原子性。
当我们查询数据之后直接释放了同步锁,这时还没有将数据存入缓存,第二个请求就拿到锁执行业务了,它看到缓存中没有数据就再次查询了数据库。所以我们需要在第一个请求拿到锁查询了数据库,并且将结果放入缓存后再释放同步锁。这样就使查询数据库和刷新缓存保持了原子性。
所以本地锁一定要保证查数据库和放缓存的原子性操作。
分布式锁
还是经典厕所🚾蹲坑模拟分布式锁,可以让所有服务在redis或者mysql中尝试添加同一条数据lock,当第一个服务请求过来,往redis存入一个key : lock,如果存入成功表示蹲到坑位,然后开始执行业务逻辑,当第二个服务想要执行业务逻辑,先往redis存入key:lock,但是这时已经有lock了,也就是这个厕所的坑位已经被别人蹲了,第二个服务线程就需要自旋等待,当第一个服务请求处理完业务逻辑,删除key:lock,释放坑位,这时第二个服务请求线程处于自旋等待,看到坑位释放,就立即放入key:lock到redis,如果放入成功就表示蹲坑成功,第二个服务请求就可以处理自身业务逻辑,处理完照样删除key:lock,以便让其他服务蹲到坑位。
这时有会引出一个问题,各个服务之间怎么判断key:lock这条数据是否存在?redis提供了一条原子性命令,如果key存在就不往缓存中放数据,让所有服务在需要使用分布式锁的地方去插入这同一条命令去占坑位。key:lock是否存在交给redis管理。并且所有的分布式锁都是基于这条命令去进行封装实现的。
感兴趣的同学可以多开几个redis的cli输入
set lock 111 NX 发送给所有的cli同时执行这条命令,会发现只有一个cli返回ok其他的都会失败。
这里还会出现一个问题,当我们的程序在删除锁之前抛出异常退出或者机器直接寄了,没有执行删锁的代码,就会造成死锁问题,所以就需要在占坑的时候设置过期时间,时间到了不管你拉没拉完直接提出厕所,但是一定要注意添加锁和设置过期时间一定要保持原子性,也就是在设置锁的时候就放上过期时间,不然如果我们拿到锁,在设置过期时间之前断电了,也会造成死锁问题。Redisson的看门狗机制会完美解决这个问题。
这样还会出现一个问题,就是我们在删除锁的时候由于服务1本身的业务逻辑超长,这时锁的自动过期时间已经把锁给删了,其他服务的请求都已经过来了,这时服务1将其他服务的锁全都删了,这肯定是不行的,每个服务只能删除自己的锁,可以给锁加一个UUID的随机值来解决。
但是给锁加UUID就行了吗?不行,如果我们的锁是10秒过期,业务逻辑9.5秒,0.3秒去获取锁的UUID,当redis把我们自己的锁的UUID给我们了,0.4秒我们的程序去处理删锁业务,但就是这多出的0.2秒,这个锁过期了,别的服务拿到锁之后生成了自己的UUID上去,我们拿到UUID删除自己的锁还是会把别人的锁给删掉。
所以必须保证删锁的原子性,可以使用 redis + lua脚本进行删除,
上面的脚本表示,如果 redis调用了get方法获取到传入的kevalue等于我们给redivalue一样就调用del方法删除这个key(lock锁占位锁)
分布式锁的究极形态就是锁的自动续期,自己实现起来肯定超级麻烦,所以Redisson会有看门狗进行解决,不使用Redissson也可以把锁的自动删除时间给的久一些,把业务逻辑代码放到try方法块,解锁放到finally方法块,不管业务逻辑代码是否出错,都去执行解锁脚本。
分布式总结:
加锁和解锁都要保证原子性,加锁使用 set lock NX time 10,设置时间。解锁使用key的随机值加 lua脚本,通过同一个key不同的value值的比对进行解锁。