1、分布式锁和本地锁
本地锁,只能锁住当前进程,集群部署中不能锁住其他进程,所以分布式中需要使用分布式锁
2、简单分布式锁实现
使用RedisTemplate操作分布式锁
// 从数据库中查询三级分类 使用分布式锁
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDBWithRedisLockLock() {
// 1、占分布式锁,去redis占坑
// Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
// 加锁原子性,加锁与设置过期时间同步执行完成
// Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111", 30, TimeUnit.SECONDS);
// 设置加锁值uuid 方便识别自己的锁后确保删除的是自己的锁
String uuid = UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 30, TimeUnit.SECONDS);
Map<String, List<Catelog2Vo>> dataFromDb = null;
if (lock) {
System.out.println("获取分布式锁成功。。。。。。。。。");
// 加锁成功
// 设置过期时间 30秒自动过期
// stringRedisTemplate.expire("lock", 30, TimeUnit.SECONDS);
try {
// 查询数据库获取数据
dataFromDb = getDataFromDb();
} catch (Exception e) {
} finally {
// stringRedisTemplate.delete("lock"); // 删除锁
// 重新获取加锁的值,确保删除的是自己的锁
// String value = stringRedisTemplate.opsForValue().get("lock");
// if (uuid.equals(value)) {
// stringRedisTemplate.delete("lock"); // 删除锁
// }
// 获取值对比+对比成功删除=原子操作 使用lua脚本操作,达到删除原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<Long>(script, Long.class);
Long execute = stringRedisTemplate.execute(defaultRedisScript, Arrays.asList("lock"), uuid); // 原子删锁
}
return dataFromDb;
} else {
// 加锁失败 重试
// 休眠100毫秒重试
System.out.println("获取分布式锁失败。。。。。。。。。");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
getCatalogJsonFromDBWithRedisLockLock(); // 自旋的方式重试
}
return dataFromDb;
}
原理是利用redis,保证所有进程操作的锁是同一把锁,并且使用了加锁与设置过期时间的原子性,解锁使用了lua脚本,达到解锁原子性。
3、Redisson 完成分布式锁
3.1、简介
Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格(In-Memory Data Grid)。充分的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。
官方文档
3.2、配置
@Configuration
public class MyRedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() throws IOException {
Config config = new Config();
// config.setTransportMode(TransportMode.EPOLL);
// config.useClusterServers()
// //可以用"rediss://"来启用SSL连接
// .addNodeAddress("redis://192.168.200.139:6379");
config.useSingleServer().setAddress("redis://192.168.200.139:6379");
return Redisson.create(config);
}
}
注意:上面代码是单服务器,使用的是config.useSingleServer().setAddress(“redis://192.168.200.139:6379”);多服务集群部署需要使用config.useClusterServers().addNodeAddress(“redis://192.168.200.139:6379”)
3.3、使用分布式锁
@ResponseBody
@GetMapping("/hello")
public String hello(){
// 1、获取一把锁,只要锁的名字一样,就是同一把锁
RLock lock = redissonClient.getLock("my-lock");
// 2、加锁
// lock.lock(); // 阻塞式等待 默认加的锁都是30s的时间
lock.lock(10, TimeUnit.SECONDS); // 设置加锁10秒自动解锁,自动解锁时间一定要大于业务的执行时间
// lock.lock(10, TimeUnit.SECONDS);问题:在锁时间到了之后看,不会自动续期
// 1、如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间。
///2、如果我们未指定锁的超时时间,就使用30 * 1000【lockWatchdogTimeout看门狗默认时间】
// 只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】
// internalLockLeaseTime / 3 【看门狗时间】/3,10s
// 1)、锁的自动续期,如果业务运行时间超长,运行期间自动给锁续上新的30s。不用担心业务时间长,锁自动过期被删掉
// 2)、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁也会默认在30s后自动删除
// 最佳实战
//1、 使用lock.lock(10, TimeUnit.SECONDS); ,省掉了整个续期操作
try {
System.out.println("加锁成功,执行业务。。。。。。。。。。。。" + Thread.currentThread().getId());
Thread.sleep(30000);
} catch (InterruptedException e) {
} finally {
// 3、解锁
// 假设解锁代码没有运行,redisson会不会出现死锁
System.out.println("释放锁。。。。。。。。" + Thread.currentThread().getId());
lock.unlock();
}
return "hello";
}
3.4、使用分布式读写锁
// 读写锁保证读到的一定是最新数据,修改期间,写锁是一个排他锁(互斥锁、独享锁),。读锁是一个共享锁。
// 写锁没释放就必须等待
// 读 + 读: 相当于无锁,并发读智慧在redis中记录好,所有当前的读锁。他们都会同时加锁成功。
// 写 + 读: 读等待写锁释放
// 写 + 写: 阻塞方式
// 读 + 写: 有读锁,写也需要等待
// 只要有写的存在,都必须等待
@ResponseBody
@GetMapping("/write")
public String writeValue(){
RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
// 加写锁
RLock rLock = lock.writeLock();
String s = "";
try {
// 1、改数据加写锁,读数据加读锁
rLock.lock();
System.out.println("写锁加锁成功:" + Thread.currentThread().getId());
s = UUID.randomUUID().toString();
Thread.sleep(30000);
redisTemplate.opsForValue().set("writeValue", s);
} catch (InterruptedException e) {
} finally {
rLock.unlock();
System.out.println("写锁释放成功:" + Thread.currentThread().getId());
}
return s;
}
@ResponseBody
@GetMapping("/read")
public String readValue(){
RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
// 加读锁
RLock rLock = lock.readLock();
System.out.println("读锁加锁成功:" + Thread.currentThread().getId());
String s = "";
rLock.lock();
try {
Thread.sleep(30000);
s = redisTemplate.opsForValue().get("writeValue");
} catch (Exception e) {
} finally {
rLock.unlock();
System.out.println("读锁释放成功:" + Thread.currentThread().getId());
}
return s;
}
3.5、Semaphore信号量
/**
* 模拟车库停车
* 3车位,
* 信号量也可以用作分布式限流
* @return
* @throws InterruptedException
*/
@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
RSemaphore park = redissonClient.getSemaphore("park"); // redis中需要提前添加一个键park值为3
// park.acquire(); // 阻塞式的 获取一个信号,获取一个值,占一个车位
boolean b = park.tryAcquire(); // 非阻塞的,返回抢占成功与否
if (b) {
// 业务执行
}else{
return "error===》" + b;
}
return "ok==》" + b;
}
@GetMapping("/go")
@ResponseBody
public String go() throws InterruptedException {
RSemaphore park = redissonClient.getSemaphore("park");
park.release(); // 释放一个车位
return "ok";
}
3.6、闭锁
/**
* 模拟放假,锁门
* 1班没人了,2班没人了。。。
* 所有班级全部走完了,我们可以锁大门了
* @return
*/
@GetMapping("/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.trySetCount(5); // 模拟设置5个班级
door.await(); // 等待闭锁都完成 阻塞式
return "放假了。。。";
}
@GetMapping("/gogogo/{id}")
@ResponseBody
public String gogogo(@PathVariable("id") String id) {
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.countDown(); // 走一次减1
return id+"班的人都走了。。。";
}
4、缓存数据一致性
如何保证缓存数据与数据库数据的一致性
4.1、双写模式
双写模式:数据更新时,写入数据库,再写入缓存
- 问题:
上图中,我们数据更新时,需要写入数据库,再写入缓存中。
假如有A、B两个请求同时过里啊,A先写入数据库1,再写入缓存1,B同时执行同样的操作,如果A由于在数据库1后因为卡顿或者网络原因等其他因素,造成写入缓1存时出现了延迟,而B此时已经写完数据库2后写完缓存了,A的写入缓1才最后执行完毕。这就导致了最后数据库中的数据是是B写的最新的,而缓存中的数据却是A写的缓存1,以至于产生了脏数据,导致数据库与缓存中数据不一致的问题。而这种脏数据也只是暂时的,在数据稳定之后,比如缓存过期以后,又能得到最新的正确数据了。数据会最终一致性。
4.2、失效模式
失效模式:数据更新时,写入数据库,删除原来的缓存,让原来的旧缓存数据失效,当新的查询过来时,发现缓存中查不到数据,就会从数据库中查询最新的数据。
- 问题
上图中,我们数据更新时,需要先写入数据库,然后删除原来的缓存数据
假如有3个请求,分别为A、B两条写请求和C读请求。由于各种原因,A在执行完删缓存后,B还在写数据库,而此时C开始读缓存,由于A已经删了缓存了,C只能去读数据库,此时C读的是A之前写的数据库,C读完数据库后,B刚刚写好数据库,删缓存,C此时把刚刚读到的A写的数据库的数据更新到缓存中,导致了实际数据库中是B写入的数据,而缓存中确实A存的数据。
4.3、改进方法
4.3.1、分布式读写锁
分布式读写锁。读数据等待写数据整个操作完成,保证了写数据时,写数据库和写缓存或删缓存达成原子性。
4.3.2、使用 cananl(待补充)
解决方案:
- 无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事,怎么办?
- 1、无论是用户维度数据(如订单数据、用户数据) ,这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
- 2、如果是菜单,商品介绍等基础数据,也可以去使用cannal订阅binlog的方式。
- 3、缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
- 4、通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓,所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据时可忽略)
总结:
- 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
- 我们不应该过度设计,增加系统的复杂性。
- 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。