这是我参与「第五届青训营 」伴学笔记创作活动的第 17 天
Redis
一、Redis入门
Redis和SQL的区别
特征
- 支持不同的数据结构
- 单线程,每个命令具有原子性
- 低延迟,速度快(基于内存、IO多路复用、良好的编码)
- 支持数据持久化
- 支持主从集群、分片集群
- 支持多语言客户端
Redis通用命令
通过help查看详细命令,www.redis.cn/commands.ht…
help 命令
set key value
get key
keys:* 、?查看符合模版的所有key,不建议在生产设备上使用
DEL:删除一个key,可以跟多个key,如果有的key不存在,则只删除存在的key
EXISTS:判断是否存在
EXPIRE:给一个key设置有效期,有效期到期key会被自动删除
ttl:查看key的剩余时间
Redis基本数据类型
String类型
String类型可以存储int,string,float
单值最大不超过512M
String常见命令
key的结构
实际使用时,通常用冒号连接多个词来拼接 key,比如 [项目名]:[业务名]:[类名]:[id]。在某些 GUI 工具中,会自动根据冒号来划分层级,浏览更方便。
Hash类型
Hash类型,也叫散列表,其value是一个无无序字典,也是key:value 类似于HashMap
Hash结构可以将对象中的每个字段独立存储,可以针对每个字段也叫CURD
常用命令
其实就是在String命令前加了h
hset
hget
hmset
hmget
hincryby
hsetnx
因为在 key之后 还有一个 filed属性,所以会多出几个对 filed和value的命令
hgetall
hkeys
hvals
List类型
与LinkList类似,是一个双向链表
有序
元素可以重复
插入和删除块
查询速度一般
相当于栈、队列等的插入弹出方式
lpush、lpop
rpush、rpop
lrange
blpop、brpop
Set类型
类似于Java中的HashSet
无序
元素不可重复
查找快
支持交集、并集、差集
常用命令
分为单集合和多集合
增:sadd
删:srem
查:scard
是否是:sismember
所有的数据:smembers
交集:sinter
差集:sdiff
并集:sunion
SortedSet类型
可排序的set集合,与TreeSet类似
可排序
元素不重复
查询速度快
常用命令
-
- 增:zadd
- 删:zrem
- 查:zscore
- 个数:zcard
- 个数:zcount 从a大小到b大小
- 增加:zincryby
- 查:zrange 第几名到第几名
Redis的Java客户端
- jedis命令和原生redis命令一样,但是线程不安全
- lettuce与Spring融合最好,基于Netty实现,线程安全
- Redisson提供和Java集合用法一致的分布式集合,线程安全
Jdis
- 引入依赖
- 创建jedis对象,密码端口
- 使用jedis对象,与redis一致
- 释放连接
private Jedis jedis;
@BeforeEach
void setUp() {
jedis = new Jedis("127.0.0.1", 6379);
jedis.auth("Swl980806");
jedis.select(0);
}
连接池
- 创建jedis对象的时候传入jedisPoolConfig对象,可以设置不同参数。对外提供方法获取
private static final JedisPool jedispool;
static {
// 配置
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(8);
jedisPoolConfig.setMaxIdle(8);
jedisPoolConfig.setMinIdle(0);
jedisPoolConfig.setMaxWaitMillis(1000);
// 创建连接池对象
jedispool = new JedisPool(jedisPoolConfig,
"127.0.0.1",6379,1000,"Swl980806");
}
Spring Data Redis
Spring Data整合封装了一系列数据集合的操作。Spring Data Redis则是封装了Jedis、Lettuce
- 提供了RedisTemplate来操作Redis
- 支持Reidis发布订阅模型
- 支持Redis哨兵和集群
- 支持响应式编程
- 支持JDK、JSON、字符串、Spring对象的数据序列化和反序列化
- 支持Redis的JDKCollection实现
步骤
- 引入依赖
- 编写配置
- 注入
- 使用
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.11.1</version>
</dependency>
spring:
redis:
host: 127.0.0.1
port: 6379
password: Swl980806
database: 0
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: 100ms
序列化
Redis Template默认使用JDK原生序列化工具
- 可读性差
- 内存占用大
优化机制
- 自定义RedisTemplate
- 使用自带的 StringRedisTemplate,key 和 value 都默认使用 String 序列化器,仅支持写入 String 类型的 key 和 value。因此需要自己将对象序列化成 String 来写入 Redis,从 Redis 读出数据时也要手动反序列化。
-
- 对象 -> json: toJSONString
- json->对象: parseObject(json,类)
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
// 创建redisTemplate对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置连接工厂
template.setConnectionFactory(redisConnectionFactory);
// 创建JSON序列化工具
GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
// 设置key序列化方式
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// 设置value
template.setValueSerializer(genericJackson2JsonRedisSerializer);
template.setHashValueSerializer(genericJackson2JsonRedisSerializer);
return template;
}
User user = new User("测试", 22);
// 手动序列化
String json = JSON.toJSONString(user);
// 写入数据
stringRedisTemplate.opsForValue().set("user:200", json);
// 获取数据
String jsonUser = stringRedisTemplate.opsForValue().get("user:200");
User user1 = JSON.parseObject(jsonUser, User.class);
System.out.println(user1);
二、Redis实战
业务场景:
共享 Session(单点登录)
介绍
为了防止多个后端服务器存储的数据不一样,导致用户访问时出现未登录状态
实现
key值设置
为了安全生成随机的token
value的去值有两种
- String:将对象JSON序列化成String类型
- 直接以hash存储,占用内存更少,且支持对单个字段的增删改查
注意事项
- 存入Redis要设置过期时间
-
- expire或者在set的时候确定
- 存入 Redis 的数据尽量保证精简和安全,比如存入用户信息时可以移除密码等敏感数据
- 已登录用户访问系统后,记得刷新 token 过期时间(续期)。并且访问任何路径时都要刷新 token,而不仅是需要登录的路径。可以新增 1 层独立的拦截器来实现 token 刷新,如下图:
拦截器
在接受请求前,需要进行判断。拦截器是基于反射机制的,可以调用service 方法
写一个拦截器类,实现HandlerInterceptor的方法
将拦截器注册进webBean中,实现WebMvcConfigurer
- 设置两个拦截器
拦截器优化
-
- 第一个拦截器对所有的请求进行请求获取token存入ThreadLocal并且刷新国旗时间
- 第二个拦截器只对需要登陆的请求进行拦截,判断Thread Local有没有数据就行了
public class RefreshTokenInterceptor implements HandlerInterceptor{
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){
}
}
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
为什么将拦截器获取的数据存入ThreadLocal中
- 对用户信息的访问,不再查询redis了,直接调用ThreadLocal对象中的数据。线程安全。
缓存
介绍
什么是缓存??
缓存就是数据交换的缓冲区, 读写性能高 ****
实现
先查缓存,有就直接返回,没有则查数据库
缓存更新策略
主动更新
- Catch Aside Pattern:缓存调用者在更新数据库的同时更新缓存
- Read/write Throuth Pattern:缓存与数据库看作一个服务,由服务维护统一性,调用者只要使用就行
- Write Behind Catching Pattern:只操作缓存,其他线程异步的将数据持久化到数据库
缓存与数据库
- 每次选择删除缓存:在更新时,先将缓存失效,等待查询时再更新缓存,防止多次无效查询
- 对于单体系统,将缓存与数据库操作放在一个事务。分布式利用TCC等分布式事务方案
- 操作数据库——>>缓存
使用
低一致性:使用redis自带的内存淘汰机制
高一致性:主动更新,兜底超时剔除
读操作:
缓存命中则直接返回
缓存未命中则查询数据库,写入缓存,设定超时时间
写操作
先写数据库,再删缓存
确保数据库与缓存的原子性
问题及解决
缓存穿透
数据在缓存与数据库中都不存在。缓存不会生效,请求全部打入数据库
1.缓存空值:比如塞一个空字符串。注意可以给空对象的键过期时间设置短一些,或者在新增数据时强制清除下对应缓存(防止查出来还是 null)
2.布隆过滤
预防做法:
-
- 增强对请求数据的校验,比如 id>0
- 增强对数据格式的控制,比如 id 设置为 10 位,不为 10 位的请求直接拒绝
- 增强用户权限校验
- 增强用户权限校验
// 缓存穿透
// 1.从redis查询商铺缓存
// 2.判断是否存在
// 3.存在,直接返回
// 判断命中的是否是空值
// 返回一个错误信息
// 4.不存在,根据id查询数据库
// 5.不存在,返回错误
// 将空值写入redis
// 返回错误信息
// 6.存在,写入redis
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
// 这个key用来向redis存取或者获取数据
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
缓存雪崩
同一时段大量的缓存key同时失效或者Redis宕机
解决方案:
- 给不同的Key的TTL添加随机数
- 利用Redis集群
- 给缓存业务添加降级限流
- 添加多级缓存
缓存击穿
高并发访问的并且重建复杂key突然失效了
解决方案
-
- 互斥锁:只有一个线程会负责缓存重建,其余线程拿不到锁,就等着
- key 设置为永不过期,在 value 中记录过期时间,业务中根据这个过期时间来判断缓存是否有效;如果缓存已过期,只有一个线程能抢到锁(然后需要再次判断是否存在缓存),开启独立线程去更新缓存,然后立即返回过期数据;其他抢不到锁的线程也立即返回过期数据,不用等着锁释放。
都是使用互斥锁来实现
- 互斥锁能保证一致性,但是等待时间长,可能会死锁
- 逻辑过期不用等,但是一致性不能保证,内存有额外消耗
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
- 原理图
用逻辑过期时间解决缓存穿透
/**
* 通过逻辑删除 解决 缓存击穿
*/
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock) {
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
用互斥锁解决缓存穿透
/**
* 通过 互斥锁 解决 缓存击穿
*/
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
全局唯一ID
介绍
- 为什么需要唯一ID
- 订单id数据,如果自增的话,规律太明显,会暴露一些信息
- 数值过大的时候,如果要分表自增,容易出现id冲突
- 全局ID生成器要满足的特征
-
- 唯一性
- 高可用
- 递增型
- 安全性
- 高性能
设计实现
生成器由两个部分组成
时间戳:当前时间的秒数 - 设置好的开始时间
LoaclDatatime.now().toExpochSecond(ZoneOffset.UTC) - LoaclDatatime.of().toExpochSecond(ZoneOffset.UTC)
序列号:字符串+哪一个项目+:+当前日期
"icr"+给定的项目名+LoaclDatatime.now().format(DateTImeFormat.ofPattern("yyyy:MM:dd"))
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
// 当前秒数
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
// 时间戳
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
秒杀业务
秒杀业务的核心流程是:判断日期和库存、扣减库存、创建订单
订单超卖问题
出现的原因:有多个人同时下单,在库存扣减前大家查到的库存都 > 0
解决方案
- ¨悲观锁:假定每次并发都会冲突,每个操作都会加锁,相当于串性执行
可以通过syn关键字实现
实现简单,但影响性能
- 乐观锁:假定并发不一定会发生冲突,只有在判断数据是否在查出来之后被其他线程修改过,在确定加不加锁。给数据加一个版本号字段,每次修改给版本号+1,在最后修改的时候加一个当前版本号=数据库当前版本号的条件
性能好,但是会存在成功率低的问题。可以对stock进行优化,改为stock>0
-
- 版本号法
- CAS:直接用库存字段代替版本号
一人一单
一个用户只能下一单,和socket问题一样,同一时间可能会有多个订单直接下单。导致一个用户超单。因为订单是需要新创建的数据,所以无法使用悲观锁和乐观锁。
单机实现
细节问题:
- synchronized 锁的范围不能太大,不能锁住整个对象,会严重影响性能。因为是一人一单,所以可以每个用户一把独立的锁。
-
- 如果在方法上加锁,会导致每一个用户每次调用该方法都加锁,严重影响效率。
- 对每个用户加锁的时候,一定要注意toString方法底层是new一个对象,会导致加锁失效,所以我们要加一个intern方法
synchronized(userId.toString().intern()){
xxx
}
- synchronized必须在使用@Transactional注解的放层使用,因为你提前释放锁而没有提交事务的时候,数据还没有更新,其他线程可以获取锁导致整个事务失败
- 调用事务方法的时候,不能使用this对象,因为@Transactional注解实际上调用的是Spring生成的代理对象的方法,使用this会无法使用事务功能。自己注入自己,生成一个代理对象。
最终
分布式实现
synchronized关键字支队单个JVM有效,多机部署时还是可能会同时有多个JVM线程访问已加锁。
一个JVM保证当前synchronized是互斥的,但是同一时间有不同的JVM的不同对象访问同一资源,只是不能加锁的。
分布式锁
基本特征:
- 多进程可见
- 互斥
特征:
- 高可用:不能挂机
- 高性能:读写快
- 安全性:不能出现死锁
实现方式
Redis分布式锁实现
获取锁:
- setnx只可以设置一个key,保证只有一个线程获取锁成功
- 必须设置过期时间以保证安全
- 可以通过设置set key value ex nx
释放锁:
- 主动释放,del key就行,放在finally里
- 超时自动释放
误删问题
情况一
线程1执行的时间太长,锁提前过期。另一个线程2拿到锁执行业务时,线程1如果这个时候业务执行结束,会误删掉线程2的锁,会导致第三个线程线程3这时拿到锁,出现线程安全问题。
情况一 - 解决方案
获取锁的时候在value中存入【本机标识+当前线程id】,释放锁之前先获取value值并判断是否等于该值,相同才能释放。
如果只存放当前线程id,可能会因为多个机器的线程有相同id而误删除。
情况2
判断锁和删除锁是两个动作,不具备原子性的。
刚判断完就产生阻塞,可能超时释放锁的时候,线程2获取锁了锁,而线程一阻塞结束后,可以直接释放锁,导致线程3可以获取锁。
情况二 - 解决方案
使用lua脚本,将多个命令放到一个脚本中
redis -> lua脚本 -> redis命令
- lua脚本通过redis.call调用redis命令
- redis通过eval调用lua脚本
lua脚本
-- 比较
if(ARGV[1] == redis.call('get',KEYS[1])) then
-- 释放锁 del key
return redis.call('del',key)
end
return 0
实现unlock
-
- RedisTemplate中的excute方法(脚本,KEYS[],ARGV[])
- 脚本我们使用一个静态参数,DefaultRedisScript是一个脚本类,使用DefaultRedisScript的setLocation方法(ClassPathResource路径类)
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
-
- KEYS[]存放在一个List集合中,使用Collections集合类方法创建一个单集合
其他问题
- 不可重入:同一个线程步伐多次获取同一把锁
- 不可重试:获取锁值尝试一次就返回,没有重试机制
- 超时释放:业务没有执行完,锁超时释放
- 主从一致性:主节点设置锁成功,还未及时同步到从节点,这时主节点宕机,从节点被选为主节点。但此时从节点还没有锁,仍可以抢锁成功
Redisson实现分布式锁
使用
- 引入redisson的包
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
- 配置一个RedissonClient客户端
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379")
.setPassword("Swl980806");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
- 使用RedissonClient
-
- getLock、tryLock、unlock
// 创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 尝试获取锁
boolean isLock = redisLock.tryLock();
// 判断
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
log.error("不允许重复下单!");
return;
}
try{
doSomething();
}finally{
redisLock.unlock();
}
可重入锁原理
使用hash结构来存储,判断该锁是不是自己的,是则value+1。释放时,判断锁是自己的先-1,在判断value是否为0
每次获取锁和释放锁都要刷新锁的有效期
获取锁的lua脚本
local key = KEYS[1];
local threadId = ARGV[1];
local releaseTime = ARGV[2];
-- 判断锁是否存在,
if(redis.call('exists', key) == 0) then
-- 不存在创建锁
redis.call('hst',key,threadId,'1');
-- 设置有效期
redis.call('expire',key,releaseTime);
return 1;
end;
-- 锁已存在,判断锁是不是自己的
if(redis.call('hexists',key,threadId) == 1) then
-- 是自己的锁
redis.call('hincrby',key,threadId,'1');
-- 设置有效期
redis.call('expire',key,releaseTime);
return 1;
end;
return 0; -- 锁不是自己的,获取锁失败
释放锁lua脚本
local key = KEYS[1];
local threadId = ARGV[1];
local releaseTime = ARGV[2];
-- 判断锁是不是自己的
if(redis.call('HEXISTS',key,threadId) == 0) then
return nil; -- 不是自己的锁,不用管
end;
-- 是自己锁
local count = redis.call('HINCRYBY',key,threadId,-1);
-- 判断锁的大小是否为0
if (count>0) then
-- 大于0说明不能释放锁,刷新时间,返回
redis.call('expire',key,releaseTime);
return nil;
else
-- =0说明可以释放锁,直接删除
redis.call('del', key);
end;
重新获取锁
基于 Redis Pub / Sub 发布订阅机制。如果获取锁失败,则阻塞订阅释放锁的消息;当锁被释放时,会触发推送(告诉其他线程我释放锁啦),然后其他线程再重试获取;如此往复,直到超时。
锁提前超时释放
基于 看门狗机制。如果不手动设置锁释放时间(leaseTime),默认设置 30 秒过期,并且给当前锁注册一个定时任务,该定时任务每隔 1 / 3 的锁释放时间(一般是 10 秒)会重置锁的过期时间(递归调用,一次续期完了再)。
出现两个问题:
- 如何保证同一个锁只注册一个定时任务
- 如何防止无限续期
要解决这些问题,使用全局 ConcurrentHashMap 来管理锁 => 任务信息,key 为锁的 id,从而保证唯一。当某个锁释放时,从全局 ConcurrentHashMap 中取出定时任务并取消掉,然后把锁的信息从 Map 中删掉即可。
主从一致性问题
问题:使用主从复制的Redis集群,可能会出现主从结点设置不一样的问题。主节点获取锁成功了,在向从节点更新锁的时候宕机了导致主从失效。
解决:使用Redisson的MultiLock来解决,开启多个独立的Redis主节点,只有所有主机点写入成功,才算设置成功。
实现MultiLock的关键:
- 遍历所有的节点,一次设置锁,使用列表来记录所有主节点是否设置成功
- 只要有一个节点设置失败,直接释放所有锁
- 获取不同的锁的时间不同,所以在所有锁设置成功后,要设置统一的过期时间,如果没有设置过期时间,会开启看门狗机制自动续期
- 锁释放时间(leaseTime)必须大于抢锁最大等待时间(waitTIme),否则会出现第一个节抢到锁后,第二个节点还没抢到锁,就被释放了。如果制定了waitTIme和leaseTime,默认leaseTIme = waitTIme * 2
RLock lock = redissonClient.getMultiLock(lock1,lock2,lock3);
lock.tryLock();
lock.unLock();
总结
不可重入锁(自定义设置的锁)
原理:利用setnx的互斥想,利用ex避免死锁,释放锁时判断线程标识
缺点:不可重入、无法重试、锁超时失效
可重入的Redis分布式锁(Redisson)
原理:利用hash结构,记录线程标识和重入次数;利用信号量控制锁重试机制;利用wathcDog延续锁超时失效
缺点:redis宕机导致锁失效
Redisson的multiLock
原理:多个独立的Redis节点,必须所有节点都获取重入锁才算成功
缺点:运维成本高、实现复杂
秒杀业务优化
优化思路:
- 串行改为并行:将需要对数据库操作的步骤进行分离。
- 同步改为异步;判断完秒杀资格后直接返回订单id。剩余操作异步执行。
优化后流程:
- 将库存信息缓存到Redis中,使用Set数据结构记录用户下单情况,在Redis中判断资格。
- 判断秒杀资格的逻辑需要原子性,封装在lua脚本中。
- 确认有资格后,将订单信息传递给阻塞队列,单个独立线程从队列中取出信息异步下单。
消息队列
jdk阻塞队列(BlockingQueue)会出现问题:
- 服务器宕机,内存队列中的订单信息会全部丢失
- 受单JVM内存限制
什么是消息队列
类似于快递柜,将生产者和消费者分离
包含3个角色:
- 消息队列:用于存储消息,快递柜
- 生产者:向消息队列中发送消息,快递员
- 消费者:从队列获取消息,取快递
好处:
- 可以保证存入队列的消息安全,不会丢失
- 对于生产者和消费者进行解耦
- 独立组件,不受JVM影响
- 保证消息一定被接受,避免线程错误而丢失
- 有序
实现方式
Redis List实现
使用List数据结构模拟队列,使用Lpush模拟生产者消息入队,使用BRPOP模拟阻塞消费者获取。没有消息则保持阻塞状态。
缺点:
- 只能存在单消费者
- 消息获取之后就删除,无法保证业务一定处理成功
Redis Pub/Sub实现
使用Redis的订阅模式,生产者给指定Channel发送消息,消费者监听指定Chnnel的消息。
- Publish channel msg
- Subscribe channel
- PSubscribe channel 条件 订阅满足条件的所有channel
缺点:
- 一次性,不会保存发送的消息,没人接收消息会丢失
- 无法数据持久化
- 缓存消息有上限,可能还是会丢失消息
Redis Stream
核心命令:
- XAdd:添加消息、创建队列,消息会自动持久化,不会丢失,每个消息都有唯一id
- XRead:读取消息,支持多消费者,可从指定消息id开始读
总结:
- 消息可回溯
- 一个消息可被多个消费者读取
- 可以阻塞队列
- 有消息漏读的风险
消费组
- 同组内竞争消费。XGROUP、XREADGroup
- 消息标识:自动记录消费的进度,支持从上次未消费的地方开始接着消费,保证每条消息按顺序消费
- 消息确认机制:默认消费的消息为pending 状态,会放到每个消费者的 pending list 中,只有消息由消费者确认(ACK),才会从 pending list 移除。这样如果消费业务处理异常,可以从 pending list 的开头依次读取未确认消息,重试处理。(也要避免无限重试,实在处理不成功就强制 ACK + 业务记日志)
几个方案比较: