商城系统
主要包括
权限服务 购物车服务 优惠券服务 用户管理服务 订单服务 商品服务 检索服务 库存服务
缓存
为了提升系统性能,我们一般会将部分数据放入缓存中,加速访问。而DB承担数据落盘工作。
哪些数据适合放入缓存中?
即时性、 数据一致性要求不高的
访问量大且更新频率不高的数据(读多, 写少)
在我的项目中将商品的三级分类放入Redis缓存中间件中,来提高首页的访问效率
使用Redis作为分布式缓存中间件
本地缓存在分布式下的问题
- 集群下的本地缓存不共享,存在于jvm中【并且负载均衡到新的机器后会重新查询】
- 数据一致性:如果一台机器修改了数据库+缓存,但是集群下其他机器的缓存未修改所以分布式情况下不使用本地缓存
在高并发下的缓存问题
缓存穿透
指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义
解决:null结果缓存,并加入短暂过期时间。 更加高级的用法 使用布隆过滤器
缓存雪崩
缓存雪崩是指在我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
解决:原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
缓存击穿
对于设置了过期时间的热点数据, 某一时间点正好过期,在高并发场景下,大量的请求都落盘到DB中。
解决:加锁。 大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去db
如何使用加锁来解决缓存击穿问题呢?
1. 本地锁synchronized
每次获取到本地锁之后,先去Redis中去查询缓存,如果缓存命中则直接返回数据。如果缓存未命中,则去DB中查询,最后将数据库DB中查到的数据放入Redis中。
2. 本地锁在分布式场景的缺陷
本地锁只能锁住当前进程, 需要分布式锁
3. 分布式锁
使用Redis实现分布式锁
本质: 去Redis中占坑,如果占到就执行逻辑。否则就必须等待(自旋的方式),直到释放锁
占锁 ----> 执行业务 ----> 释放锁
使用Redis set NX 命令实现占锁
分布式锁演进-阶段一
//1.占分布式锁,去redis占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
if (lock){
//加锁成功...执行业务
Map<String, List<Catalog2Vo>> dataFromDb = getDataFromDb();
//删除锁
redisTemplate.delete("lock");
return dataFromDb;
}else {
//加锁失败...重试
//休眠100ms重试
return getCatalogJsonFromDbWithRedisLock();//自旋的方式
}
存在问题:setnx占好位置,业务代码异常或者程序宕机导致没有执行删除锁的逻辑,就造成了死锁
解决方法:设置锁的自动过期时间。即使没有删除,会自动删除
分布式锁演进-阶段二
//分布式锁
public Map<String, List<Catalog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1.占分布式锁,去redis占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
if (lock){
//设置过期时间 发生断电
redisTemplate.expire("lock",30,TimeUnit.MINUTES);
//加锁成功...执行业务
Map<String, List<Catalog2Vo>> dataFromDb = getDataFromDb();
//删除锁
redisTemplate.delete("lock");
return dataFromDb;
}else {
//加锁失败...重试
//休眠100ms重试
return getCatalogJsonFromDbWithRedisLock();//自旋的方式
}
}
存在问题:设置过期时间遇到的问题。setnx设置好,正要去设置过期时间,宕机。又死锁了。
解决方法:设置过期时间和占位必须是原子的。redis支持使用setnx ex命令 阶段三解决
分布式锁演进-阶段三
Redis 加锁并设置过期时间(原子性)
redisTemplate.opsForValue().setIfAbsent("lock", "111",300,TimeUnit.SECONDS);
存在问题:删除锁直接删除???如果由于业务时间很长,锁自己过期了,我们直接删除,有可能把别人正在持有的锁删除了。
解决方法:占锁的时候,值指定为uuid,每个人匹配是自己的锁才删除。 阶段四解决
分布式锁演进-阶段四
存在问题:如果正好判断是当前值,正要删除锁的时候,锁已经过期,别人已经设置到了新的值。那么我们删除的是别人的锁
解决:删除锁必须保证原子性 即 【判断+删除】的原子性。 使用redis+Lua脚本完成 阶段五完成
分布式锁演进-阶段五
LUA脚本
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
// 1、占本分布式锁。去redis占坑,同时设置过期时间
String uuid = UUID.randomUUID().toString();
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300,TimeUnit.SECONDS);
if (lock) {
// 加锁成功....执行业务【内部会判断一次redis是否有值】
System.out.println("获取分布式锁成功....");
Map<String, List<Catalog2Vo>> dataFromDB = null;
try {
dataFromDB = getDataFromDb();
} finally {
// 2、查询UUID是否是自己,是自己的lock就删除
// 查询+删除 必须是原子操作:lua脚本解锁
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call('del',KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
// 删除锁
Long lock1 = redisTemplate.execute(new DefaultRedisScript<Long>(luaScript, Long.class),
Arrays.asList("lock"), uuid);
}
存在问题:问题:业务执行时间比较长,可以延长过期时间
总结
-
死锁问题:说的是锁没有被动过期时间,即拿到了锁,但是在执行业务过程中,程序崩溃了,锁没有被正常释放,导致其他线程无法获取到锁,从而产生死锁问题。
-
之前的解决方式:set nx ex命令
-
锁被其他人释放问题:第一条线程抢到锁,业务执行超时,第一条线程所持有的锁被自动释放;此时第二条线程拿到锁,准备执行业务,刚好第一条线程业务执行完成,照常执行了释放锁的过程,导致第二条线程持有的锁被第一条线程所释放,锁被其他人释放。
-
之前的解决方式:给锁一个唯一值(UUID),每次解锁前进行判断
-
原子性操作:原子性的意思就是要么都成功,要么都失败。像我们获取锁,设定值和设定时间是两步操作,让他们变成原子性操作就是设定值和设定时间成为一体,一起成功或者一起失败。另外解锁操作的获取锁,判断锁是否为当前线程所拥有,也必须是一个原子性操作。
-
之前的解决方式:利用Redis中的lua脚本实现。
-
锁自动续期问题:这个就是利用了我们今天的Redisson来实现的
使用Redisson分布式锁
Redis实现简易的分布式锁存在的问题: 锁无法自动续期
Redis分布式锁无法自动续期,比如,一个锁设置了1分钟超时释放,如果拿到这个锁的线程在一分钟内没有执行完毕,那么这个锁就会被其他线程拿到,可能会导致严重的线上问题,我已经在秒杀系统故障排查文章中,看到好多因为这个缺陷导致的超卖了。
Redisson介绍
Redisson 实现并扩展了JUC包下的诸多接口功能,应用于分布式的场景
所以Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟
在本次测试中my-lock的初始过期时间TTL为30s,但是每到20s(经过三分之一看门狗时间后)就会自动续借成30s
@ResponseBody
@GetMapping("/hello")
public String hello() {
//1.获取一把锁,只要锁的名字一样,就是同一把锁
RLock lock = redissonClient.getLock("my-lock");
//加锁
lock.lock();//阻塞式等待,默认加的锁等待时间为30s
//1.锁的自动续期,如果在业务执行期间业务没有执行完成,redisson会为该锁自动续期
//2.加锁的业务只要运行完成,就不会自动续期,即使不手动解锁,锁在默认的30s后会自动删除
try {
System.out.println("加锁成功,执行业务......"+Thread.currentThread().getId());
Thread.sleep(30000);
}catch (Exception e){
}finally {
//解锁,假设代码没有运行,redisson不会出现死锁
System.out.println("锁释放..."+Thread.currentThread().getId());
lock.unlock();
}
return "hello";
}
lock.lock() 是阻塞式等待 区别于之前的自旋式等待
解锁,假设代码没有运行,redisson不会出现死锁??
Redisson主要实现了哪些功能?
1.锁的自动续期,如果在业务执行期间业务没有执行完成,redisson会为该锁自动续期
如果业务时间超长,运行期间会自动给锁续上新的30s,不用担心业务时间超长,锁自动过期被删掉
2.加锁的业务只要运行完成,就不会自动续期,即使不手动解锁,锁在默认的30s后会自动删除
Redisson看门狗机制
一旦设置了锁的过期时间,看门狗机制锁的自动续期失效。所以自动解锁时间一定要大于业务的执行时间
1.如果我们传递了锁的超时时间, 就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间
2.如果我们未指定锁的超时时间,就使用30 * 1000 LockWatchdogTimeout(看门狗的默认时间);
只要占锁成功,就会启动一个定时任务(重新给锁设置过期时间,新的过期时间就是看门狗的默认时间),每隔10s续期到30s
internalLockLeaseTime(看门狗时间) / 3 , 10s一续期
* 最佳实战
* lock.lock(10, TimeUnit.SECONDS); 省掉了续期操作,给足时间 手动解锁
Redisson具体实现
1.Redisson 是如何实现加锁的
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
tryLockInnerAsync() 实现加锁
// KEY[1]: 就是锁的名称,对于我们的demo来说,就是myLock
// ARGV[1]: 就是锁的过期时间,不指定的话默认是30s
// ARGV[2]: 代表了加锁的唯一标识,由UUID和线程id组成 一个Redisson客户端一个UUID,UUID代表了一个唯一的客户端。所以由UUID和线程id组成了加锁的唯一标识,可以理解为某个客户端的某个线程加锁。
// field(这里值redisson固定为1)-->ARGV[2](组成部分:当前RedissonLock的uuid:当前线程的值)
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
Lua脚本是Redis已经内置的一种轻量小巧语言,其执行是通过Redis的eval /evalsha 命令来运行,把操作封装成一个Lua脚本,如论如何都是一次执行的原子操作。
Redisson底层也是使用Lua脚本来实现的,这段 lua 脚本分为三个部分:
第一部分:加锁(底层是hash的数据结构)
-
首先用 exists 判断了 KEYS[1] (即 redisson:lock)是否存在。 如果不存在,则进入第 5 行,使用 hincrby 命令创建一个新的哈希表,如果域field不存在,那么在执行命令前会被初始化为0,此命令的返回值就是执行hincrby命令后,哈希表key中域field的值,此时进行increment,也就是返回1,之后进入第6行,对KEY[1]设置过期时间,30000ms然后返回nil。
第二部分:重入
-
首先判断KEY[1]是否存在,因为KEY[1]是一个hash结构,所以13行意思是获取这个KEYS[1]中字段为ARGV[2]也就是UUID:threadId这个值是否存在。
-
如果存在进入14行代码对其进行加1操作(锁重入) 然后进入15行重新设置过期时间30s 然后返回nil
第三部分:返回:作用就是返回 KEY[1] 的剩余存活时间
2.Redisson 是如何解锁的
redis.call('publish', KEYS[2], ARGV[1]);同时发布了一个事件,这个是干嘛的?是去通知正在等待获取锁其他的线程们,可以使用这把锁了
3.Redisson 是如何实现锁自动续期的(Redisson解决的主要问题)
使用Netty内部的一个工具类,主要运用了时间轮算法
因为有了看门狗机制,所以说如果你没有设置过期时间(超时自动释放锁的逻辑后面会说)并且没有主动去释放锁,那么这个锁就永远不会被释放,因为定时任务会不断的去延长锁的过期时间,造成死锁的问题。但是如果发生宕机了,是不会造成死锁的,因为宕机了,服务都没了,那么看门狗的这个定时任务就没了,也自然不会去续约,等锁自动过期了也就自动释放锁了,跟上述说的为什么需要设置过期时间是一样的
总结
-
在使用 Redisson 获取锁的过程,你主动设定了锁的过期时间,`Redisson 将不会开启看门狗机制。
-
Redisson 在 Redis 中保存的结构是一个 Hash的数据结构,key 的名称是我们的锁名称,如案例中使用的 redisson:lock,存储的字段名称为 UUID:threadId,值的话就是 1
-
Redisson实现锁自动续期的底层是开启了一个线程,异步的执行定时任务,在锁还剩下20s,自动续期为30s,此定时任务是采用Netty框架中的时间轮算法实现。
Redisson主要几方面:
-
加锁:
1.原生Redis使用SetNx Ex来实现加锁的原子性 Redisson使用LUA脚本来实现。
2.Redisson 在 Redis 中保存的结构是一个 Hash的数据结构,key 的名称是我们的锁名称,如案例中使用的 redisson:lock,存储的字段名称为 UUID:由UUID和线程id组成,值的话就是 1。 所以由UUID和线程id组成了加锁的唯一标识,可以理解为某 个客户端的某个线程加锁。
3.Redisson也是一样为了防止死锁,默认会设置一个30s的过期时间
4.同时Redisson使用LUA脚本来实现可重入锁
5.如何实现不同线程加锁的互斥:
因为lua脚本加锁的逻辑同时只有一个线程能够执行(redis是单线程的原因),所以一旦有线程加锁成功,那么另一个线程来加锁,前面两个if条件都不成立,最后通过调用redis的pttl命令返回锁的剩余的过期时间回去
-
解锁
-
Redisson使用LUA脚本来实现解锁
-
指定和不指定超时时间的主要区别是,加锁成功之后的逻辑不一样,不指定超时时间时,会开启watchdog后台线程,不断的续约加锁时间,而指定超时时间,就不会去开启watchdog定时任务,这样就不会续约,加锁key到了过期时间就会自动删除,也就达到了释放锁的目的
-
-
锁的自动续期
-
定期执行如下的一段lua脚本来实现加锁时间的延长
-
后台开启一个线程,使用看门狗的机制来自动延长加锁的时间在客户端通过tryLockInnerAsync方法加锁成功之后,如果你没有指定锁过期的时间,那么客户端会起一个定时任务,来定时延长加锁时间,默认每10s执行一次。所以watchdog的本质其实就是一个定时任务。
-
所以总的来说,加锁的lua脚本实现了第一次加锁、可重入加锁和加锁互斥的逻辑。
缓存一致性的问题
1.双写模式:在数据库进行写操作的同时对缓存也进行写操作,确保缓存数据与数据库数据的一致性
存在问题: 两个线程同时进行写操作时由于缓存是存储在redis,写缓存时需要发送网络请求,导致虽然线程一先发送写缓存的网络请求但是比线程二发送的写缓存的网络请求后到达redis,造成数据被覆盖
2.失效模式:在数据库进行更新操作时,删除原来的缓存,再次查询数据库就可以更新最新数据
存在问题:当两个请求同时修改数据库,一个请求已经更新成功并删除缓存时又有读数据的请求进来,这时候发现缓存中无数据就去数据库中查询并放入缓存,在放入缓存前第二个更新数据库的请求成功,这时候留在缓存中的数据依然是第一次数据更新的数据
解决方案
无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
1、如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可
2、如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
3、缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
4、通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略);
总结:
• 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
• 我们不应该过度设计,增加系统的复杂性
• 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。