目录
一、初识Redis
1、认识NoSQL
总结:
对于扩展性:关系型数据库的数据都是存储在本机的,故影响其性能的就是本机这台机器的服务器的性能,只能提升该机器的性能来提升其能力。虽然像Mysql这样的是支持主从的,但主从仅仅提升了机器的数量及读写的性能,但是并不能提升数据存储的量,因为你主和从存的数据是一模一样的。数据存储的总量并没有发生变化,只不过做了备份而已。与此对应,对于非关系型数据库来说,无论是redis还是elasticsearch也好,他们在设计之初就考虑到了数据拆分的需求。故他们在插入数据的时候往往会采用数据的id或唯一的标识去做一个哈希运算,根据哈希运算来判断这个数据到底应该存储在哪一个不同的节点上,从而实现数据的拆分,天然的就支持这种水平的扩展。(虽然Mysql默认情况下是不支持水平扩展的,但是也可以基于第三方的组件来实现数据库的分库。当然,一旦引入第三方组件肯定会对性能造成影响并且在开发的时候需要考虑的问题也会更多,复杂度就会增加)
SQL&NoSQL的选择:
数据业务的数据结构相对固定或数据业务对于一致性和安全性要求较高,建议使用关系型数据库;如下订单,订单数据就属于安全性要求较高的数据,肯定需要用关系型数据库存储,当然为了查询性能,我们可以冗余的把部分订单数据放到NoSQL数据库里去提升他的查询效率。所以两者结合使用。因此非关系型数据库往往就是用于一些数据结构不固定,且对于一致性和安全性要求不高,但是对于产品性能要求较高的场景下去使用。在实际开发中,根据自己需求灵活的去选择即可
2、认识Redis
❓redis6.0已经变成多线程?
答:仅仅针对网络请求处理这一块,而核心的命令的执行这部分依然是单线程的
❓为什么redis明明是单线程性能却这么好?
答:1.基于内存(核心原因⭐:磁盘与内存差异太大)
2.基于IO多路复用(大大提高了整个服务的吞吐能力)
3.良好的编写(基于C语言编写,写得好👍)
3、安装Redis
二、Redis常见命令
1、5种常见数据结构
2、通用命令
3、不同数据结构的操作命令
补充: redis的key的格式:[项目名]:[业务名]:[类型]:[id]
RANGE命令中stop参数给-1表示想要获取所有元素
三、Redis的Java客户端
1、Jedis客户端
Jedis的官网地址:https://github.com/redis/jedis
Jedis快速入门
- Jedis使用的基本步骤
Jedis连接池
Jedis本身是线程不安全,故我们在使用时需要为每一个线程创建独立的Jedis对象。但频繁的创建和销毁链接会有性能损耗,故我们使用Jedis连接池代替Jedis的直连方式
◽ 最大空闲连接指的是即便没人访问这个池子,池子也可以预备8个连接供给使用。即,当有人从池子中取Jedis对象时就不用再临时创建了,直接使用即可
2、SpringDataRedis客户端
SpringDataRedis
JDKCollection:JDK中各种各样的集合,springdataredis基于redis重新实现了一下这些集合。如,队列、链表等等;
为什么要重新实现呢:由于基于redis的这种实现是分布式、跨系统的。所以springdataredis对这些做了重新的实现。
使用SpringDataRedis对Redis操作的核心:RedisTemplate
springdataredis中提供了redistemplate工具类,其中封装了各种对redis的操作。并且将不同数据类型的操作API封装到了不同的类型中
SpringDataRedis快速入门
基于springboot去使用:由于springboot已经默认整合了springdataredis并且做了自动装配,使用起来会极其的方便。
- 1.
连接池依赖:由于无论是jdeis还是lettuce,底层都会基于commons-pool来实现连接池效果,所以需要去引入这个依赖
1.springboot自动装配的好处就是不用再去写编码了。如创建jedis连接池时,代码仍有一定复杂度。但基于springboot时,只需在yml文件配置一些信息即可,如ip地址、端口号、密码及连接池信息等;
2.连接池可选jedis或lettuce。若选择jedis需要再在pom文件中引入jedis相关依赖,因为springdataredis默认使用的是lettuce;
3.一定要在yml中手动配置 jedis/lettuce pool 连接池才会生效
总结:
SpringDataRedis的序列化方式
RedisTemplate可以接收任意Object作为值写入Redis,只不过写入前会把Object序列化为字节形式,默认是采用JDK序列化,得到的结果是这样的:
redistemplate的set方法接收的参数并不是字符串而是Object。这是基于springdataredis的一个特殊功能,它可以接收任何类型的对象,然后将其转成redis可以处理的字节。故我们存进去的键或值都被当成了java对象,而redistemplate底层默认对这些对象的处理方式就是利用jdk的序列化工具ObjectOutputStream
缺点:① 可读性差;② 内存占用较大。 故,若想要所见即所得,就必须去改变redistemplate的序列化方式了。
一般情况下key均为字符串,就可以用StringRedisSerializer来修改key的序列化;而值可能是对象,是对象时,可以用GenericJackson2JsonRedisSerializer来修改值的序列化。用JdkSerializationRedisSerializer在redis中存储的是字节流,用StringRedisSerializer存储的是字符串,而用GenericJackson2JsonRedisSerializer存储的是json字符串
我们可以自定义RedisTemplate的序列化方式,代码如下:
redistemplate的构建需要连接工厂,但工厂不需要我们自己创建,因为工厂会由springboot帮我们自动创建,我们只需要注入进来即可
存入字符串:
存入对象:
当对象以json字符串形式存入redis时同时还会存入一个该类的字节码名称。因为有这样一条属性,所以才能在反序列化时读取到类的名称字节码,从而将json字符串精准反序列化为该类对象并返回
缺点:为了在反序列化时知道对象的类型,json序列化器会将类的class类型写入json结果中,存入redis,会带来额外的内存开销。 故,为了节省内存空间,我们并不会使用JSON序列化器来处理value,而是统一使用String序列化器,要求只能存储String类型的key和value。当需要存储Java对象时,手动完成对象的序列化和反序列化,如下图所示。
但我们无需自定义一个键值均为String的RedisTemplate,因为Spring默认提供了一个StringRedisTemplate类,它的key和value的序列化方式默认就是String方式。省去了我们自定义RedisTemplate的过程,代码如下:
总结:
补充:对hash类型的操作
redis图形化界面:
四、基于黑马点评项目进行Redis实战训练
1、短信验证
2、商户查询缓存
(1)缓存
实战
(2)缓存更新策略
- 目前企业中常见的三种主动更新的模式
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存 (胜出)
缓存速度远远高于数据库
以上两种方案均有可能发生线程的安全问题,但 先操作数据库,再删除缓存 相对来讲出现问题的可能性更低。(在 先操作数据库,再删除缓存 后再加上一个超时时间,保证即使写了旧数据,过一段时间也会被清除)
实战
(3)缓存穿透
实战
(4)缓存雪崩
(5)缓存击穿(热点key)
实战
JMeter——Apache JMeter 是 Apache 组织基于 Java 开发的并发、压力测试工具,用于对软件做压力测试。
秒查询率QPS是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准。
(6)缓存工具封装
基于StringRedisTemplate封装一个缓存工具类:
package com.hmdp.utils;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 将任意Java对象序列化为json并存储在String类型的key中,并且可以设置TTL过期时间
*
* @param key
* @param value
* @param time
* @param unit
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
// JSONUtil.toJsonStr(value)->需要将对象序列化为json字符串
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 将任意Java对象序列化为json并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
*
* @param key
* @param value
* @param time
* @param unit
*/
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));
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透的问题
*
* @param keyPrefix key的前缀
* @param id
* @param type 泛型的实际类型
* @param dbFallback 数据库查询函数[函数式编程可以传递函数][传递函数的特点:有参数、返回值]
* @param time
* @param unit
* @param <R>
* @param <ID>
* @return
*/
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
// 拼接key
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查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
*
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param unit
* @param <R>
* @param <ID>
* @return
*/
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);
// 写入redis
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
/**
* 互斥锁解决缓存击穿
*
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param unit
* @param <R>
* @param <ID>
* @return
*/
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;
}
/**
* 获取锁
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
调用工具类:
@Override
public Result queryById(Long id) {
// 解决缓存穿透
Shop shop = cacheClient
.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
// Shop shop = cacheClient
// .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//
// 逻辑过期解决缓存击穿
// Shop shop = cacheClient
// .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 7.返回
return Result.ok(shop);
}
3、优惠券秒杀
(1)基于Redis的全局唯一ID生成策略
实战:
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@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;
}
}
全局唯一ID生成策略:
① UUID(jdk)[16进制、字符串]
② Redis自增
③ snowflake算法/雪花算法(不依赖于redis)[long类型64位数字]
④ 数据库自增(单独创建一张表用于维护自增id从而实现全局唯一id效果)
Redis自增ID策略:
① 每天一个key,方便统计订单量
② ID构造是 时间戳 + 计数器
(2)超卖问题
乐观锁方案一:
乐观锁方案二(Compare and Set/Switch):
总结:
如若遇到只能通过数据是否变化来判断线程是否安全的情况时,可以使用分段锁。即将数据分散至多个表中,减小锁锁定的资源,从而提高更新的成功率。[ ConcurrentHashMap中也用到了这种分段锁的思想]
(3)一人一单
加悲观锁时要根据业务需求尽可能的缩小锁定资源范围。P54
在集群模式或分布式系统下有多个jvm的存在,每个jvm都有自己的锁,导致每一个锁都可以有一个线程获取,于是就出现了并行运行,线程安全问题:
若想解决这个问题我们需要使多个jvm使用同一把锁。这种锁不是jdk所提供的,需要我们自己去实现(跨jvm、跨进程锁)。
注意如下代码还存在一个事务的问题:在这里是对当前实现类的createVoucherOrder方法加了事务,没有给调用createVoucherOrder方法的方法加事务。而调用createVoucherOrder方法的方法是通过this,也就是当前实现类对象来调用的createVoucherOrder方法,而不是通过代理对象。我们知道事务要想生效是因为spring对类做了动态代理,spring拿到类的代理对象后用他(类的代理对象)来去做的事务处理。而现在this指的是非代理对象,也就是目标对象,所以他是没有事务功能的,这也是spring事务失效的几种可能性之一
所以若想让事务生效,我们就需要去拿到事务代理的对象才可以。这里可以借助api->AopContext中的currentProxy方法拿到当前对象(在这里实际上就是IVoucherOrderService接口)的代理对象,再利用获取到的该代理对象去调用createVoucherOrder方法。此时被调用的createVoucherOrder方法就会被spring管理了,因为获取到的代理对象是由spring创建的,所以通过获取到的代理对象调用的createVoucherOrder方法也就带有了事务特性,改进后的代码如下:
补充:
① 由于方法createVoucherOrder是被IVoucherOrderService接口的代理对象调用的,所以要确保在IVoucherOrderService接口中声明了createVoucherOrder方法
② 要想借助api->AopContext中的currentProxy方法成功获取到当前对象的代理对象首先需要在pom.xml文件中引入aspectjweaver(AspectJWeaver 是 AspectJ 框架的一部分,是一个用于实现面向切面编程(AOP)的工具)依赖,其次还需要在启动类上添加用于暴露代理对象的注解@EnableAspectJAutoProxy(exposeProxy = true)(exposeProxy默认值为false,即不暴露代理对象)
(4)分布式锁
分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:
基于Redis的分布式锁,实现分布式锁时需要实现的两个基本方法:
以上解决方案仍可能出现并发问题,首先由于业务阻塞可能导致锁提前释放,但当该业务醒来完成过后会立马将他人的锁释放掉,从而又会导致多个线程都拿到锁的情况(redis分布式锁误删问题),如下图:
解决方案:获取锁标识并判断是否一致。
但上述解决方案仍可能出现问题,即当线程将要释放锁但由于jvm垃圾回收等机制造成的阻塞时间够长时,就有可能触发超时释放锁。(即,由于判断锁标识和释放锁是两个动作,而在这两个动作间产生了阻塞),如下图:
解决方案:确保判断标识和释放锁两个动作成一个原子性的操作,也就是说一起执行不能间隔。使用Lua脚本。
Lua中数组下标是从1开始的
基于Redis的分布式锁:
如果使用Lua脚本来表示则是这样的:
再次改进Redis的分布式锁:
示例代码:
package com.hmdp.utils;
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
/*@Override
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}*/
}
unlock.lua:
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
基于Redis的分布式锁优化
基于setnx实现的分布式锁存在下面的问题:
1.不可重入:同一个线程无法多次获取同一把锁。如,有一个方法a调用方法b,在方法a中要先去获取锁,然后执行业务去调用b,而b中又要去获取同一把锁。那在这种情况下,若锁是不可重入的就会出现死锁的情况
2.不可重试:获取锁只尝试一次就返回false,没有重试机制。它是一种非阻塞式,但往往某些业务需要锁是阻塞的
3.超时释放:锁超时释放虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁释放,存在安全隐患
4.主从一致性:如果Redis提供了主从集群,那么主从同步存在延迟。而会出现这样一种情况,即,当主节点获取锁且没来得及同步到从节点时就宕机了,此时会选择一个从节点作为新的主节点,而这个从节点上由于没有完成同步,所以没有锁的标识。也就是说,这个时候其他线程可以趁虚而入去拿到锁,那这个时候就等于是有多个线程拿到锁,所以可能会在极端情况下出现安全问题。当然问题出现的概率比较低,因为主从同步的延时往往是极低的,它往往可以在ms级别甚至更低
总结: 以上4个问题要么出现概率极低,要么就是业务不一定有这样的需求。但若对锁的要求很高,我们就需要解决以上4个问题。而解决这4个问题又很麻烦,所以不推荐自己去实现,而是使用成熟的框架来帮助我们去实现。我们可以用组件Redisson(Redisson是在Redis基础上实现的一个分布式工具的集合,也就是说,在分布式系统下要用到的各种各样的工具Redisson都有。包括分布式锁(分布式锁只是其中的一个子集)。)来解决以上的问题。
以后在使用分布式锁时,推荐直接使用如Redisson这样的开源框架去实现
Redisson入门
redisson有两种配置方式,可利用java配置,也可以利用yml文件跟springboot去整合实现。并且官方还提供了一个redisson的springboot-starter,但并不推荐使用这种方式,因为它会替代spring官方提供的对于redis的这套配置和实现。所以建议在使用分布式锁的时候自己来配置redisson即可,不要与springboot里边对redis的配置混在一起。
RedissonConfig.java:
package com.hmdp.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置类
Config config = new Config();
// 添加redis地址,useSingleServer表明redisson用的是单节点的redis;也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassword("123321");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
使用Redisson的分布式锁:
Redisson可重入锁原理
流程图:
获取锁Lua脚本:
释放锁的Lua脚本:
测试:
通过追溯源码我们发现Redisson底层实现与上述逻辑基本一致,核心源码如下:
注:Redisson采用哈希结构存储数据,这里不要将key与value中的field混淆。key中记录的是锁的名称,field记录的是线程标识,value记录的是锁的重入次数,数据结构如下图所示:
Redisson的锁重试和WatchDog机制
流程图:
只要给了waitTime值,Redisson底层就会执行重试机制。源码如下:
// 无参
public boolean tryLock() {
return (Boolean)this.get(this.tryLockAsync());
}
public RFuture<Boolean> tryLockAsync() {
return this.tryLockAsync(Thread.currentThread().getId());
}
public RFuture<Boolean> tryLockAsync(long threadId) {
return this.tryAcquireOnceAsync(-1L, -1L, (TimeUnit)null, threadId);
}
// 全参
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
} else {
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
current = System.currentTimeMillis();
RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId); // subscribe->订阅,订阅其他线程释放锁的信号
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
this.unsubscribe(subscribeFuture, threadId);
}
});
}
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
try {
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
boolean var20 = false;
return var20;
} else {
boolean var16;
do {
long currentTime = System.currentTimeMillis();
ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId); // 第一次进入到do...while循环执行tryAcquire方法时,是第一次进行重试
if (ttl == null) {
var16 = true;
return var16;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}
currentTime = System.currentTimeMillis();
if (ttl >= 0L && ttl < time) {
((RedissonLockEntry)subscribeFuture.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
((RedissonLockEntry)subscribeFuture.getNow()).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
} while(time > 0L);
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}
} finally {
this.unsubscribe(subscribeFuture, threadId);
}
}
}
}
}
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
return (Long)this.get(this.tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1L) {
return this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e == null) {
if (ttlRemaining == null) {
this.scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
}
// 无leaseTime参
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {
return this.tryLock(waitTime, -1L, unit);
}
由源代码可知,Redisson底层实现锁重试机制是在tryLock的全参方法中执行的。而只有当给了waitTime参数最终才会调用到tryLock的全参方法,所以只要给了waitTime值,Redisson底层就会执行重试机制
当leaseTime=-1时,Redisson底层才会执行WatchDog看门狗机制去进行自动续约。源码如下:
// this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()
private long lockWatchdogTimeout;
public Config() {
this.lockWatchdogTimeout = 30000L;
}
public long getLockWatchdogTimeout() {
return this.lockWatchdogTimeout;
}
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1L) {
return this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e == null) {
if (ttlRemaining == null) {
// P67 16:15~24:30
this.scheduleExpirationRenewal(threadId); // 这里只有在ttlRemaining == null条件成立,即,成功获取锁后进行过期时间续约的任务调度
}
}
});
return ttlRemainingFuture;
}
}
// 过期时间续约的任务调度(一定是在某一线程成功获取锁后执行)
private void scheduleExpirationRenewal(long threadId) {
RedissonLock.ExpirationEntry entry = new RedissonLock.ExpirationEntry();
// private static final ConcurrentMap<String, RedissonLock.ExpirationEntry> EXPIRATION_RENEWAL_MAP = new ConcurrentHashMap();
// putIfAbsent确保了不管锁重入了多少次,将来拿到的永远是同一个entry
RedissonLock.ExpirationEntry oldEntry = (RedissonLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry);
// oldEntry != null 在这里,其实不同线程是不可能拿到同一把锁的,所以这里一定是同一个线程多次来获取,其实是一种重入
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
this.renewExpiration(); // 如果是第一次进行该方法,除了加上对应线程外,还会多做一个renew(续约;更新有效期)的动作
}
}
public static class ExpirationEntry {
private final Map<Long, Integer> threadIds = new LinkedHashMap(); // 👈
private volatile Timeout timeout;
public ExpirationEntry() {
}
public synchronized void addThreadId(long threadId) { // 👈
Integer counter = (Integer)this.threadIds.get(threadId);
if (counter == null) {
counter = 1;
} else {
counter = counter + 1;
}
this.threadIds.put(threadId, counter);
}
public synchronized boolean hasNoThreads() {
return this.threadIds.isEmpty();
}
public synchronized Long getFirstThreadId() {
return this.threadIds.isEmpty() ? null : (Long)this.threadIds.keySet().iterator().next();
}
public synchronized void removeThreadId(long threadId) {
Integer counter = (Integer)this.threadIds.get(threadId);
if (counter != null) {
counter = counter - 1;
if (counter == 0) {
this.threadIds.remove(threadId);
} else {
this.threadIds.put(threadId, counter);
}
}
}
public void setTimeout(Timeout timeout) {
this.timeout = timeout;
}
public Timeout getTimeout() {
return this.timeout;
}
}
// 更新有效期
private void renewExpiration() {
RedissonLock.ExpirationEntry ee = (RedissonLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
if (ee != null) {
// Timeout -> 超时任务(三个参数)-> 1.任务本身;2.delay->延时时长;3.时间单位。即,任务是在delay时间到期后执行,所以是个延时任务
Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
public void run(Timeout timeout) throws Exception {
RedissonLock.ExpirationEntry ent = (RedissonLock.ExpirationEntry)RedissonLock.EXPIRATION_RENEWAL_MAP.get(RedissonLock.this.getEntryName());
if (ent != null) {
Long threadId = ent.getFirstThreadId();
if (threadId != null) {
RFuture<Boolean> future = RedissonLock.this.renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", e);
} else {
if (res) {
RedissonLock.this.renewExpiration(); // 递归
}
}
});
}
}
}
}, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS); // this.internalLockLeaseTime为内部锁释放时间
ee.setTimeout(task); // 将定时任务放到entry中
}
}
// 刷新有效期
// lua脚本用于执行判断当前锁是否为当前线程获取,若是则重置有效期并返回0;否则直接返回1
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return this.evalWriteAsync(this.getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(this.getName()), this.internalLockLeaseTime, this.getLockName(threadId));
}
由源码可知,当leaseTime != -1L 时,直接返回tryAcquireAsync方法结果;而当leaseTime == -1L 时,Redisson底层会调用scheduleExpirationRenewal方法执行WatchDog看门狗机制去进行自动续约
锁释放->任务取消,源码如下:
public RFuture<Void> unlockAsync(long threadId) {
RPromise<Void> result = new RedissonPromise();
RFuture<Boolean> future = this.unlockInnerAsync(threadId);
future.onComplete((opStatus, e) -> { // future执行成功后执行cancelExpirationRenewal方法来取消更新任务
this.cancelExpirationRenewal(threadId);
if (e != null) {
result.tryFailure(e);
} else if (opStatus == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: " + this.id + " thread-id: " + threadId);
result.tryFailure(cause);
} else {
result.trySuccess((Object)null);
}
});
return result;
}
// 取消更新任务
void cancelExpirationRenewal(Long threadId) {
RedissonLock.ExpirationEntry task = (RedissonLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
if (task != null) {
if (threadId != null) {
task.removeThreadId(threadId); // 移除线程id
}
if (threadId == null || task.hasNoThreads()) {
Timeout timeout = task.getTimeout();
if (timeout != null) {
timeout.cancel(); // 取消任务
}
EXPIRATION_RENEWAL_MAP.remove(this.getEntryName()); // 移除entry
// 到这儿整个定时任务就删除的干干净净了,锁的释放也就完成了
}
}
}
参数:
waitTime->获取锁的最大等待时长
leaseTime->锁失效自动释放时间
总结:
Redisson的multiLock原理
若使用单节点redis时这台redis发生故障,那么所有依赖于redis的业务都会出现问题,包括分布式锁等。在一些核心业务中肯定是不允许发生这样的情况的,所以为了解决这个问题,提高redis的可用性,在实际应用中,往往会去搭建redis的主从模式。
主从:多台redis,角色不同。一台作为主节点(处理写操作),剩下的作为从节点(处理读操作)。为了能在从节点读到数据,所以主从之间需要做数据的同步,主节点会不断的把自己的数据同步给从节点确保主从之间数据是一致的。但是毕竟不是在一台机器上,所以主从之间会有一定的延时,数据的同步也就会存在一定的延时。主从一致性问题正是因为这样的延时而导致的。如,现在有一个java应用要来获取锁并在主节点保存了这样一个锁的标识,但就在主节点要向从节点进行数据同步时,主节点发生了故障,也就是同步尚未完成。此时redis会有哨兵去监控集群状态,当他发现主线程宕机后,首先客户端连接会断开并从redis从节点中选出一个节点作为新的主节点。但是因为之前主从同步尚未完成,也就是说锁已经丢失,所以此时java应用再来访问新的主节点时就会发现锁已经没有了,也就是说锁失效。此时如果再有其他线程来获取锁也能获取成功,就会出现并发的安全问题。这就是主从一致性导致的锁失效问题。
而Redisson解决主从一致性问题的思路非常简单粗暴。既然主从关系是导致一致性问题发生的原因,那干脆就不要主从了。所有节点都变成独立的redis节点,相互之间没有任何关系,没有主从,都可以做读写。那么此时,获取锁的方式就变了。以前获取锁只需要找到master节点(主节点),然后在他里面获取锁;但是现在必须依次向多个redis节点都去获取锁,都保存了这个锁的标识才算获取锁成功。
同时也可以给每一个节点建立主从关系,让他们做主从同步:
而这套方案在redis中叫MultiLock,即,联锁-把多个独立的锁联合在一起变成一个联合的锁。在使用这样的锁的时候也是较为灵活的,可以只建立几个独立节点,不建主从关系;也可以建立主从关系,让他的可用性变得更强。
示例代码:
package com.hmdp.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MultiRedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置类
Config config = new Config();
// 添加redis地址,useSingleServer表明redisson用的是单节点的redis;也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassword("123321");
// 创建RedissonClient对象
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient2(){
// 配置类
Config config = new Config();
// 添加redis地址,useSingleServer表明redisson用的是单节点的redis;也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.150.101:6380");
// 创建RedissonClient对象
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient3(){
// 配置类
Config config = new Config();
// 添加redis地址,useSingleServer表明redisson用的是单节点的redis;也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.150.101:6381");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
package com.hmdp;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.boot.test.context.SpringBootTest;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Slf4j
@SpringBootTest
class MultiRedissonTest {
@Resource
private RedissonClient redissonClient;
@Resource
private RedissonClient redissonClient2;
@Resource
private RedissonClient redissonClient3;
private RLock lock;
@BeforeEach
void setUp() {
RLock lock1 = redissonClient.getLock("order");
RLock lock2 = redissonClient2.getLock("order");
RLock lock3 = redissonClient3.getLock("order");
// 创建联锁 multiLock
// 使用任意一个RedissonClient去获取联锁都可以,因为追溯源码可知,Redisson底层是new了一个RedissonMultiLock并返回的
lock = redissonClient.getMultiLock(lock1, lock2, lock3);
/**
* 源码:
* public RLock getMultiLock(RLock... locks) {
* return new RedissonMultiLock(locks);
* }
*
* locks存储多个独立的锁;
* 按照联锁的原理,将来线程在获取锁时,应该依次将这个集合内的每一把锁都尝试去获取一遍,都成功了才算成功
* final List<RLock> locks = new ArrayList();
* public RedissonMultiLock(RLock... locks) {
* if (locks.length == 0) {
* throw new IllegalArgumentException("Lock objects are not defined");
* } else {
* this.locks.addAll(Arrays.asList(locks));
* }
* }
*/
}
// 拿到锁对象后,锁的使用方式与之前使用方式无异
@Test
void method1() throws InterruptedException {
// 尝试获取锁
boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
if (!isLock) {
log.error("获取锁失败 .... 1");
return;
}
try {
log.info("获取锁成功 .... 1");
method2();
log.info("开始执行业务 ... 1");
} finally {
log.warn("准备释放锁 .... 1");
lock.unlock();
}
}
void method2() {
// 尝试获取锁
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败 .... 2");
return;
}
try {
log.info("获取锁成功 .... 2");
log.info("开始执行业务 ... 2");
} finally {
log.warn("准备释放锁 .... 2");
lock.unlock();
}
}
}
RedissonMultiLock.class源码:
❓其实还是不太懂不重试就要将释放时间改为 waitTime * 2 对后续获取锁有什么作用❓
❓若释放时间太短导致一个线程释放锁后另一个线程立马抢到一直到最后一个锁都这样,不就会出现两个线程都拿到锁的情况吗,这种问题该怎么避免(除了将释放时间设的大一点)❓
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long newLeaseTime = -1L;
if (leaseTime != -1L) { // 这里 leaseTime != -1 成立代表不会触发Redisson底层的WatchDog看门狗机制
// 只有当 leaseTime != -1L 时才处理释放时间,因为若将leaseTime设置成其他值,后续就没办法触发WatchDog看门狗机制实现自动续约了
// 若 waitTime == -1 ,说明只想获取一次,也就是不重试,此时释放时间给多久就设定为多久;但若 waitTime != -1 说明想要做重试,就会用 waitTime * 2 来替代释放时间。因为万一释放时间小于等待时间,可能会发生还没重试完就释放掉的情况,就会出现问题。所以在这里会放弃 leaseTime 而使用 waitTime * 2
if (waitTime == -1L) {
newLeaseTime = unit.toMillis(leaseTime);
} else {
newLeaseTime = unit.toMillis(waitTime) * 2L;
}
}
long time = System.currentTimeMillis();
long remainTime = -1L;
if (waitTime != -1L) {
remainTime = unit.toMillis(waitTime); // remainTime就是剩余等待时间
}
long lockWaitTime = this.calcLockWaitTime(remainTime); // 跟进calcLockWaitTime方法会发现该方法返回的就是remainTime,也就是说其实锁等待时间和剩余等待时间是一样的
int failedLocksLimit = this.failedLocksLimit(); // 跟进failedLocksLimit方法发现返回的是0,也就是说获取锁失败的限制是0
List<RLock> acquiredLocks = new ArrayList(this.locks.size()); // 已获取的锁:获取成功的锁
ListIterator iterator = this.locks.listIterator();
// 遍历联锁中每一把锁并尝试获取
while(iterator.hasNext()) {
RLock lock = (RLock)iterator.next();
boolean lockAcquired;
try {
if (waitTime == -1L && leaseTime == -1L) { // 没传waitTime,即只获取一次
lockAcquired = lock.tryLock();
} else {
long awaitTime = Math.min(lockWaitTime, remainTime);
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
}
} catch (RedisResponseTimeoutException var21) {
this.unlockInner(Arrays.asList(lock));
lockAcquired = false;
} catch (Exception var22) {
lockAcquired = false;
}
if (lockAcquired) {
acquiredLocks.add(lock); // 获取锁成功->放入已获取锁集合中
} else { // 获取锁失败
if (this.locks.size() - acquiredLocks.size() == this.failedLocksLimit()) { // 判断联锁内锁的个数与获取锁的个数是否等于获取锁失败的限制个数0
break; // 若为0表示已获取所有锁,则跳出循环
}
if (failedLocksLimit == 0) {
this.unlockInner(acquiredLocks); // 将已经拿到的锁释放掉
if (waitTime == -1L) { // waitTime == -1L 表示不想做重试,一次失败直接失败
return false;
}
failedLocksLimit = this.failedLocksLimit();
acquiredLocks.clear(); // 先清空所有拿到的锁
while(iterator.hasPrevious()) { // 将迭代器循环向前迭代至第一个,即重置指针位置
iterator.previous();
}
} else {
--failedLocksLimit;
}
}
if (remainTime != -1L) { // 超过剩余时间,获取锁失败,释放已获取锁并返回失败结果false
remainTime -= System.currentTimeMillis() - time;
time = System.currentTimeMillis();
if (remainTime <= 0L) {
this.unlockInner(acquiredLocks);
return false;
}
}
}
❓两个循环中执行的expireAsync和syncUninterruptibly两个方法作用分别是什么❓
// 若所有锁获取成功且指定了leaseTime(即,Redisson底层不会触发WatchDog看门狗机制,不会执行自动续约),为了解决获取锁时造成每一把锁剩余有效期不一致的问题,这里会循环获取每一把锁并重新设置一下有效期
if (leaseTime != -1L) {
List<RFuture<Boolean>> futures = new ArrayList(acquiredLocks.size());
Iterator var24 = acquiredLocks.iterator();
while(var24.hasNext()) {
RLock rLock = (RLock)var24.next();
RFuture<Boolean> future = ((RedissonLock)rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);
futures.add(future);
}
var24 = futures.iterator();
while(var24.hasNext()) {
RFuture<Boolean> rFuture = (RFuture)var24.next();
rFuture.syncUninterruptibly();
}
}
return true; // for循环顺利结束表示已成功获取联锁中的所有锁,即,获取联锁成功,返回成功结果true
}
// 要么把所有锁都成功拿到结束;
// 要么就失败重试直到所有锁都成功拿到或超过剩余时间获取锁失败;
// 或者遇到 waitTime==-1 ,即不重试且在获取某一个锁时失败则直接失败
补充:
在 Redisson 的 RFuture 接口中,syncUninterruptibly 方法通常用于同步地获取 RFuture 的结果,并且在此过程中不会响应中断。换句话说,即使当前线程被其他线程中断,它也会继续等待 RFuture 的结果完成,而不是抛出 InterruptedException。
这种行为在某些场景下可能是有用的,尤其是当你希望确保操作完成,而不管是否有其他线程尝试中断当前线程时。但是,请注意,这可能会导致你的程序在某些情况下响应不够灵敏,因为被中断的线程不会立即停止它正在做的事情。
与 syncUninterruptibly 相对应的是 sync 方法。sync 方法也会同步地获取 RFuture 的结果,但它会响应中断。如果当前线程在等待结果时被中断,那么 sync 方法会抛出 InterruptedException。
总的来说,选择使用 syncUninterruptibly 还是 sync 取决于你的具体需求和你希望你的程序如何响应中断。
小结:
(5)Redis优化秒杀
流程图:
示例代码:
// ① 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到Redis中👈添加代码
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
-- ② 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 2.数据key ..作用:连接字符串,相当于java中的+
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0
// ③ 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// 创建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 2.2.为0 ,有购买资格,把下单信息保存到阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);
// 3.返回订单id
return Result.ok(orderId);
}
// ④ 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// 创建线程池
// executor->处理器;SECKILL_ORDER_EXECUTOR->专门来做秒杀订单的处理器
// newSingleThreadExecutor 静态方法获取单线程的,因为在这里处理订单时速度也不需要特别快,所以给一个线程即可
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
// @PostConstruct注解 使得被修饰的方法在当前类初始化完毕后就会执行
@PostConstruct
private void init() {
// submit->提交任务
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 创建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
// 可以使用匿名或者内部类的方式
// 内部类
// 要保证任务在实现类初始化后就执行->使用spring提供的注解去做
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
// 不断从队列中获取订单数据
while (true){
try {
// 1.获取队列中的订单信息
// take->阻塞方法,获取并删除该队列的头部(第一个元素),如果需要则等待直到元素可用
// 不用担心会对cpu带来负担 ,因为take方法发现没有元素就会卡在这,有元素才会往下走
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 由于现在是多线程,是从线程池里获取的一个全新的线程,不是主线程去做,所以就不能再从UserHolder中去取了,因为此线程的ThreadLocal中是没有该信息的
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
// 创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 尝试获取锁
boolean isLock = redisLock.tryLock();
// 判断
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
// 由于是异步执行,结果不再返回给前端,故无需再返回一个Result了
// 且其实本身可以不用加锁,因为在redis中已经加了并发判断了。又加一次锁只是为了兜底,以防万一redis出了问题没有判断成功,虽然这种可能性几乎没有,但是我们该做的判断还是要做一下。理论上是不可能发生并发安全问题的,因为redis已经做过判断了
log.error("不允许重复下单!");
return;
}
try {
/*
* 优化前生成订单逻辑:
* IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
* return proxy.createVoucherOrder(voucherId);
* 但在优化后是无法获取到事务代理对象的,与获取userId类似,currentProxy方法底层也是使用ThreadLocal类对象去获取事务代理对象。
* 而此时业务是基于一个新的线程,子线程去做的,而不是父线程。作为一个子线程是没有办法从ThreadLocal中取出你想要的东西的。所以我们可以将代理对象设为成员变量供所有线程获取并在主线程中提前获取事务对象。
* */
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
@Transactional
public Result createVoucherOrder(VoucherOrder voucherOrder) {
// 5.一人一单
Long userId = voucherOrder.getUserId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
log.error("用户已经购买过一次!");
return;
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
log.error("库存不足!");
return;
}
// 7.创建订单
save(voucherOrder);
}
private IVoucherOrderService proxy;
@Override
public Result seckillVoucher(Long voucherId) {
// 获取用户
Long userId = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
// 2.判断结果是否为0
int r = result.intValue();
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 2.2.为0 ,有购买资格,把下单信息保存到阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);
// 3.获取代理对象👈提前获取事务对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 4.返回订单id
return Result.ok(orderId);
}
总结:
(6)Redis消息队列实现异步秒杀
基于List结构模拟消息队列
消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。
队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。
不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP(B-Block-阻塞)来实现阻塞效果。
总结:
基于PubSub的消息队列
总结:
基于Stream的消息队列
Stream 是 Redis 5.0 引入的一种新数据类型(Stream 是 Redis 专门为消息队列设计的一种数据类型,所以他是支持数据持久化的),可以实现一个功能非常完善的消息队列。
单消费者模式
发送消息的命令:
例如:
读取消息的方式之一:XREAD
例如,使用XREAD读取第一个消息:
XREAD阻塞方式,读取最新的消息(给0就是永久阻塞):
在业务开发中,我们可以循环的调用XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下:
总结:
消费者组模式
采用消费者组模式进行消息读取解决在单消费者模式下存在消息漏读风险的问题。
消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:
也就是说,多个消费者只要在一个组里,他们之间就是竞争关系。凡是进入这个组的消息,组内所有消费者都可以去抢。如此,处理消息的速度就大大加快了,从一定程度上就可以避免消息堆积的问题。若想让某个消息被多个消费者消费,只需要加多个消费者组即可
消费者组会维护一个标识记录最后一个被处理的消息。如果说消费者出现了故障或者宕机或在处理过程中又来了很多新消息,由于有了消息标识,消费者能够直到上次读到了哪里,下次就可以接着读。这样就可以确保每一个消息都会被消费,不会再出现漏掉消息的情况了。那么,在单消费者模式下存在消息漏读的风险也就得以解决了
pending指待处理,每个消费者都有自己的pending-list。即,当消费者拿到消息后并不代表这个消息就已经处理完成了,需要通过xack向redis明确表明该条消息已经处理完。而redis一旦接收到消费者发送的确认信息,才会把这个消息标记成已处理并将该消息从pending-list移除。消息确认解决了消息丢失的问题:如,假设某个消费者拿到了一个消息,这个消息会处于pending状态(待处理状态),如果此时出现了如宕机等情况,这个消息也不会消失,而是在列表中处于待处理状态。那么当消费者恢复后,只需要查看pending-list就可以知道哪些是上一次没处理完的消息并继续处理完成即可。故,消息确认机制可以确保消费者至少会消费一次他所获取到的所有消息
创建消费者组:
- key:队列名称
- groupName:消费者组名称
- ID:起始ID标示,$代表队列中最后一个消息,0则代表队列中第一个消息
- MKSTREAM:队列不存在时自动创建队列
其它常见命令:
一般情况下,我们并不需要自己去添加消费者。因为当我们从组中指定一个消费者并监听消息时,若redis发现这个消费者不存在就会自动帮我们创建出来,所以并不需要手动去创建
从消费者组读取消息:
NOACK:若给了NOACK就代表不用消费者确认了,消息投递给消费者(也就是消费者获取到消息)那一刻会自动确认,也就是说消息根本不会进入pending-list,那么就有可能会出现消息丢失的问题。所以一般建议不要配(设置)NOACK参数
XGROUP + XREADGROUP:
XACK:
XPENDING:
消费者监听消息的基本思路:
总结:
小结:
在redis中选择消息队列的话推荐使用Stream,但是如果项目的业务比较庞大,对于消息队列的要求更加严格,建议还是使用更加专业的消息队列:比如RabbitMQ,RocketMQ等等。这是因为Stream虽然支持消息的持久化,但这种持久化是依赖于redis本身持久化的。而redis的持久化其实也不能保证万无一失,还是有丢失风险的。而且Stream的消息确认机制只支持消费者的确认机制,而不支持生产者确认机制。那如果说是生产者在发消息的过程中丢失了就没办法处理了。另外还有消息的事务机制,在多消费者下的消息有序性等等。这些问题都需要更加强大的消息队列去支持
基于Stream消息队列实现异步秒杀
示例代码:
# ① 创建一个Stream类型的消息队列,名为stream.orders
XGROUP CREATTE stream.orders g1 0 MKSTREAM
由于队列只需要在一开始的时候创建完成,以后就不需要再去创建了,所以这里也不去使用java代码来实现了,直接利用命令行将队列和组一次性一起创建出来
-- ② 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
// ③ 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
@PostConstruct
private void init() {
// submit->提交任务
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有异常消息,结束循环
break;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
private void createVoucherOrder(VoucherOrder voucherOrder) {
// 由于现在是多线程,是从线程池里获取的一个全新的线程,不是主线程去做,所以就不能再从UserHolder中去取了,因为此线程的ThreadLocal中是没有该信息的
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
// 创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 尝试获取锁
boolean isLock = redisLock.tryLock();
// 判断
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
// 由于是异步执行,结果不再返回给前端,故无需再返回一个Result了
// 且其实本身可以不用加锁,因为在redis中已经加了并发判断了。又加一次锁只是为了兜底,以防万一redis出了问题没有判断成功,虽然这种可能性几乎没有,但是我们该做的判断还是要做一下。理论上是不可能发生并发安全问题的,因为redis已经做过判断了
log.error("不允许重复下单!");
return;
}
try {
/*
* 优化前生成订单逻辑:
* IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
* return proxy.createVoucherOrder(voucherId);
* 但在优化后是无法获取到事务代理对象的,与获取userId类似,currentProxy方法底层也是使用ThreadLocal类对象去获取事务代理对象。
* 而此时业务是基于一个新的线程,子线程去做的,而不是父线程。作为一个子线程是没有办法从ThreadLocal中取出你想要的东西的。所以我们可以在主线程中提前获取事务对象。
* */
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
log.error("不允许重复下单!");
return;
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
log.error("库存不足!");
return;
}
// 7.创建订单
save(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 3.返回订单id
return Result.ok(orderId);
}
4、达人探店
发布探店笔记
查看探店笔记
点赞功能
补充:
redisTemplate.opsForSet().isMember(key, value)
· 当key不存在,返回false
· 当value存在于key的set之中,返回true
· 当value不存在于key的set之中,返回false
点赞排行榜
示例代码:
// 点赞
@Override
public Result likeBlog(Long id) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前登录用户是否已经点赞
String key = BLOG_LIKED_KEY + id;
// 通过查元素分数的命令判断元素是否存在,查到了即存在,返回空即不存在
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if (score == null) {
// 3.如果未点赞,可以点赞
// 3.1.数据库点赞数 + 1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 3.2.保存用户到Redis的set集合 zadd key value score
// 将时间戳作为集合元素的分数值,以此实现能按插入时间排序
if (isSuccess) {
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}
} else {
// 4.如果已点赞,取消点赞
// 4.1.数据库点赞数 -1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2.把用户从Redis的set集合移除
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}
// 根据笔记id查询笔记
@Override
public Result queryBlogById(Long id) {
// 1.查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
}
// 2.查询blog有关的用户
queryBlogUser(blog);
// 3.查询blog是否被点赞
isBlogLiked(blog);
return Result.ok(blog);
}
// 分页查询笔记
@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog -> {
this.queryBlogUser(blog);
this.isBlogLiked(blog);
});
return Result.ok(records);
}
// 查询当前用户是否点赞过当前博客
private void isBlogLiked(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
// 用户未登录,无需查询是否点赞
return;
}
Long userId = user.getId();
// 2.判断当前登录用户是否已经点赞
String key = "blog:liked:" + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(score != null);
}
// 查询点赞列表
@Override
public Result queryBlogLikes(Long id) {
String key = BLOG_LIKED_KEY + id;
// 1.查询top5的点赞用户 zrange key 0 4
// 名词从0开始
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 2.解析出其中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
// 3.根据用户id查询用户 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)
List<UserDTO> userDTOS = userService.query()
.in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list() // 保证用in的同时是按in中给的元素顺序进行查询的;由于mp中的orderBy不支持field功能,所以使用last(表示最后一条sql语句,会在原有sql语句后拼接)来添加要执行的语句
.stream() // 转换成流
.map(user -> BeanUtil.copyProperties(user, UserDTO.class)) // 映射
.collect(Collectors.toList()); // 收集
// 4.返回
return Result.ok(userDTOS);
}
5、好友关注
关注和取关
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
// 1.判断到底是关注还是取关
if (isFollow) {
// 2.关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess) {
// 把关注用户的id,放入redis的set集合 sadd userId followerUserId
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userId).eq("follow_user_id", followUserId));
if (isSuccess) {
// 把关注用户的id从Redis集合中移除
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}
共同关注
@Override
public Result followCommons(Long id) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
// 2.求交集
String key2 = "follows:" + id; // 目标用户
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);👈
if (intersect == null || intersect.isEmpty()) {
// 无交集
return Result.ok(Collections.emptyList());
}
// 3.解析id集合
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
// 4.查询用户
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
借助redis-set集合中求交集功能实现
Feed流实现方案分析
总结:
推送到粉丝收件箱
示例代码:
// ① 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
// ② 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
@Override
public Result saveBlog(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 2.保存探店笔记
boolean isSuccess = save(blog);
if(!isSuccess){
return Result.fail("新增笔记失败!");
}
// 3.查询笔记作者的所有粉丝 select * from tb_follow where follow_user_id = ?
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
// 4.推送笔记id给所有粉丝
for (Follow follow : follows) {
// 4.1.获取粉丝id
Long userId = follow.getUserId();
// 4.2.推送
String key = FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 5.返回id
return Result.ok(blog.getId());
}
滚动分页查询
传统分页模式:按角标分页-ZREVRANGE(名次从0开始)
改进:滚动分页-ZREVRANGEBYSCORE-参数:
max:当前时间戳 | 上一次查询的最小时间戳
min:0
offset:0 | 在上一次的结果中,与最小值一样的元素的个数
count:3
示例代码:
// ③ 查询收件箱数据时,可以实现分页查询
// 核心:(1)分析出本次结果中的最小时间戳作为下一次查询的起始值(最大值);(2)找到本次查询结果中与最小值值一样的元素的个数作为下一次查询的偏移量
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
// 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
String key = FEED_KEY + userId;
// tuple->元组
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 3.非空判断
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// 4.解析数据:blogId、minTime(时间戳)、offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0; // 2
int os = 1; // 2
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { // 5 4 4 2 2
// 4.1.获取id
ids.add(Long.valueOf(tuple.getValue()));
// 4.2.获取分数(时间戳)
long time = tuple.getScore().longValue();
if(time == minTime){
os++;
}else{
minTime = time;
os = 1;
}
}
// 5.根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Blog blog : blogs) {
// 5.1.查询blog有关的用户
queryBlogUser(blog);
// 5.2.查询blog是否被点赞
isBlogLiked(blog);
}
// 6.封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}
6、附近的商户
GEO数据结构的基本用法
导入店铺数据到GEO
示例代码:
@Test
void loadShopData() {
// 1.查询店铺信息
List<Shop> list = shopService.list();
// 2.把店铺分组,按照typeId分组,typeId一致的放到一个集合
Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 3.分批完成写入Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
// 3.1.获取类型id
Long typeId = entry.getKey();
String key = SHOP_GEO_KEY + typeId;
// 3.2.获取同类型的店铺的集合
List<Shop> value = entry.getValue();
// 3.3.写入redis GEOADD key 经度 纬度 member
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
for (Shop shop : value) {
// stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
实现附近商户功能
示例代码:
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1.判断是否需要根据坐标查询
if (x == null || y == null) {
// 不需要坐标查询,按数据库查询
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
// 2.计算分页参数
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
// 3.查询redis、按照距离排序、分页。结果:shopId、distance
String key = SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
);
// 4.解析出id
if (results == null) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() <= from) {
// 没有下一页了,结束
return Result.ok(Collections.emptyList());
}
// 4.1.截取 from ~ end的部分
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
// 4.2.获取店铺id
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 4.3.获取距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
// 5.根据id查询Shop
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
// 6.返回
return Result.ok(shops);
}
7、用户签到
BitMap用法
实现签到功能
示例代码:
@Override
public Result sign() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
统计连续签到
示例代码:
@Override
public Result signCount() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
if (result == null || result.isEmpty()) {
// 没有任何签到结果
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
// 6.循环遍历
int count = 0;
while (true) {
// 6.1.让这个数字与1做与运算,得到数字的最后一个bit位->判断这个bit位是否为0
if ((num & 1) == 0) {
// 如果为0,说明未签到,结束
break;
}else {
// 如果不为0,说明已签到,计数器+1
count++;
}
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1; // 无符号右移并赋值
}
return Result.ok(count);
}
8、UV统计
HyperLogLog的用法
测试百万数据的统计
利用单元测试,向HyperLogLog中添加100万条数据,看看HyperLogLog的内存占用及统计效果如何,示例代码:
@Test
void testHyperLogLog() {
String[] values = new String[1000];
int j = 0;
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
values[j] = "user_" + i;
if(j == 999){
// 发送到Redis
stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
}
}
// 统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
System.out.println("count = " + count);
}
结果:
占用内存:(1552424-1538040)/1024=14.046875KB
info memor命令用于查看redis内存