Redis
关于Redis我们先要问:什么是Redis?
个人认为它是一种key - value 存储系统
数据结构
String 字符串类型: name: "xxx"
List 列表:names: ["cat", "dog"]
Set 集合:names: ["cat", "dog"](值不能重复)
Hash 哈希:person: { "name": "张三", "age": 18 }
Zset 集合:persons: { person1 - 9, peron2 - 12 }(适合做排行榜)
geo(计算地理位置)
hyperloglog(pv / uv)(可以来做页面访问)
pub / sub(发布订阅,类似消息队列)
BitMap (1001010101010101010101010101)(用来做每月签到)
bloomfilter(布隆过滤器,主要从大量的数据中快速过滤值,比如邮件黑名单拦截)
使用
自定义序列化类
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(RedisSerializer.string());
return redisTemplate;
}
}
Java中的实现方式
Spring Data Redis(推荐)
1、引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.6.4</version>
</dependency>
2、配置Redis地址
spring: # redis 配置 redis: port: 6379 //端口号 host: localhost //主机名 database: 0 //数据存储在redis的哪个数据库上
Jedis
独立于 Spring 操作 Redis 的 Java 客户端
要配合 Jedis Pool 使用
Lettuce
高阶 的操作 Redis 的 Java 客户端
异步、连接池
Redisson(分布式)
分布式操作 Redis 的 Java 客户端,让你像在使用本地的集合一样操作 Redis(分布式 Redis 数据网格)。就是功能更加丰富版本的 RedisTemplate,经常使用为分布式锁(它解决了分布式系统中的多个节点互斥访问的问题)。
-
互斥
-
防止死锁
-
可重入
-
自动续期(看门狗机制)
-
高性能
-
....
Redisson中的看门狗机制
如果线程仍旧没有执行完,那么redisson会自动给redis中的目标key延长超时时间,这在Redisson中称之为 Watch Dog 机制。 如果拿到分布式锁的节点宕机,且这个锁正好处于锁住的状态时,会出现锁死的状态,为了避免这种情况的发生,锁都会设置一个过期时间。这样也存在一个问题,加入一个线程拿到了锁设置了30s超时,在30s后这个线程还没有执行完毕,锁超时释放了,就会导致问题,便有了 watch dog 自动延期机制。 Redisson提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放。 默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。 另外Redisson 还提供了可以指定leaseTime参数的加锁方法来指定加锁的时间。超过这个时间后锁便自动解开了,不会延长锁的有效期 阅读源码总结: 1、watch dog 在当前节点存活时每10s给分布式锁的key续期 30s; 2、watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期; 3、如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
JetCache
比较:
1. 如果你用的是 Spring,并且没有过多的定制化要求,可以用 Spring Data Redis,最方便 2. 如果你用的不是 SPring,并且追求简单,并且没有过高的性能要求,可以用 Jedis + Jedis Pool 3. 如果你的项目不是 Spring,并且追求高性能、高定制化,z可以用 Lettuce,支持异步、连接池 4. 如果你的项目是分布式的,需要用到一些分布式的特性(比如分布式锁、分布式集合),推荐用 redisson
使用场景
1、普通缓存数据
比如:
1)使用string存储 验证码
//这里是项目中存储后端返回给用户的验证码操作 String code = RandomUtil.randomNumbers(6); //保存验证码到Redis stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone,code,2, TimeUnit.MINUTES);
2)使用hash存储用户信息(通过这个,加上拦截器中设置,就是redis+session的登录校验)
//1、随机生成token作为登录令牌
String token = UUID.randomUUID().toString();
//2、将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString())
);
//3、利用hash存储用户信息,比直接使用 String 结构是更为节省空间
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
//4、设置token有效期(缓存是非常珍贵的,一般都要设置有效期)
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
2、消息队列
1)介绍:
消息队列:就是存放消息的队列。它提供了一种高效、可靠、异步的通信方式,能够降低系统的耦合性,提高系统的扩展性、可维护性和可靠性。 个人认为消息队列只是redis的一个补充,而不是重点,这个redis实现消息队列只适合轻量级,而且需要看情况来实现。 最简单的消息队列包括3个角色: 1、消息队列: 2、生产者:发送消息到消息队列 3、消费者:从消息队列获取消息并处理 生产者 -》 消息队列 -》 消费者 生产者:发送消息 消费者:接收消息
2)实现
Redis中提供了3种不同方式实现消息队列: 1、list结构:基于list结构模拟消息队列 优点:基于Redis持久化,数据安全有保证;消息有序 缺点:无法避免消息丢失;只能被读一次 2、PubSub:基本的点对点消息模型 (一个生产者可对多个消费者) 优点:采用发布订阅模型,支持多生产,多消费 缺点:不支持数据持久化;无法避免数据丢失;消息堆积有限,超限则丢失 3、Stream:比较完善的消息队列模型(5.0以上引入,专门为消息队列而生) 特点: 1)可回溯(消息消费完不会被删除) 2)可以多消费者争抢消息,加速消费 3)可以阻塞读取 4)没有消息漏读风险(会标记上次消费的位置) 5)有消息确认机制,保证消息至少被消费一次 基于Stream的消息队列——消费者组
# * 表示让 Redis 为插入的数据自动生成一个全局唯一的 ID # 往名称为 mymq 的消息队列中插入一条消息,消息的键是 name,值是 sid10t 127.0.0.1:6379> XADD mymq * name sid10t "1665058759764-0" #从消息队列mymq中读取消息 127.0.0.1:6379> XREAD STREAMS mymq 1665058759764-0 (nil) 127.0.0.1:6379> XREAD STREAMS mymq 1665058759763-0 1) 1) "mymq" 2) 1) 1) "1665058759764-0" 2) 1) "name" 2) "sid10t" #通过BLOCK实现阻塞读 # 命令最后的 $ 符号表示读取最新的消息 127.0.0.1:6379> XREAD BLOCK 10000 STREAMS mymq $ (nil) (10.01s) # 创建一个名为 group1 的消费组,0-0 表示从第一条消息开始读取。 127.0.0.1:6379> XGROUP CREATE mymq group1 0-0 OK # 创建一个名为 group2 的消费组,0-0 表示从第一条消息开始读取。 127.0.0.1:6379> XGROUP CREATE mymq group2 0-0 OK # 命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。 #消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了,即同一个消费组里的消费者不能消费同一条消息。但是不同的消费者组可以再去读这条消息。就是说每个消息只可在消费者组中读一次 127.0.0.1:6379> XREADGROUP GROUP group1 consumer1 STREAMS mymq > 1) 1) "mymq" 2) 1) 1) "1665058759764-0" 2) 1) "name" 2) "sid10t" #使用XPENDING来查看一下 group2 中各个消费者已读取、但尚未确认的消息个数 127.0.0.1:6379> XPENDING mymq group2 1) (integer) 4 2) "1665058759764-0" 3) "1665060634962-0" 4) 1) 1) "consumer1" 2) "2" 2) 1) "consumer2" 2) "1" 3) 1) "consumer3" 2) "1" 小结: 1、消息保序:XADD/XREAD 2、阻塞读取:XREAD block 3、重复消息处理:Stream 在使用 XADD 命令,会自动生成全局唯一 ID; 4、消息可靠性:内部使用 PENDING List 自动保存消息,使用 XPENDING 命令查看消费组已经读取但是未被确认的消息,消费者使用 XACK 确认消息; 5、支持消费组形式消费数据
3、计数器
1)点赞统计(比如说这下面是一个博客点赞统计)
//1、获取当前用户id
Long userId = UserHolder.getUser().getId();
//2、判断是否点赞
String key = RedisConstants.BLOG_LIKED_KEY + id;
//获得score值,判断当前用户是否对这个博客点赞
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
//3、未点赞,可以点赞
if(score == null){
//3.1、数据库的liked点赞数+1
boolean success = update().setSql("liked = liked +1").eq("id", id).update();
//3.2、保存用户到redis的set集合,方便判断是否点赞
if(success){
stringRedisTemplate.opsForZSet().
add(key,userId.toString(),System.currentTimeMillis());
}
}else{
//4、已经点赞,取消点赞
//4.1、数据库点赞-1
boolean success = update().setSql("liked = liked -1").eq("id", id).update();
//4.2、把用户从redis的set集合删除
if(success) {
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
2)网站点击量——UV统计
先搞懂两个概念:
-
UV:独立访问量,只记录某个用户单次
-
PV:页面访问量,记录每次访问网站次数
Hyperloglog是loglog算法派生的概率算法。特点:相比于传统的集合统计方法,HyperLogLog 具有更小的内存占用和更快的计算速度。
//这里是一个模拟网页点击的测试
@Test
void testHyperLogLog(){
String[] values = new String[1000];//1000个用户
int j = 0;
for(int i =0;i<1000000;i++){ //访问页面次数
j=i%1000;
values[j] = "user_" + i;
if(j == 999){
stringRedisTemplate.opsForHyperLogLog().add("dj",values);
}
}
Long count = stringRedisTemplate.opsForHyperLogLog().size("dj");
System.out.println(count); //运行结果:997593,所耗空间:12.02kB
}
4、分布式锁
借用Redis的SETNX实现分布式锁,确保在分布式系统中的并发安全性
//获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
5、排行榜
这里利用zset实现了一个点赞排行榜,按最先点赞排序
String key = RedisConstants.BLOG_LIKED_KEY + id;
//取出前5个点赞的
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if(top5 == null || top5.isEmpty()){
return Result.ok(Collections.emptyList());
}
//这里就是对取出的数据进行操作了
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
List<UserDTO> userDTOs = userService.query().in("id", ids).last("order by field(id," + idStr + ")")
.list().stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
6、共同关注
利用set集合实现
//1、获取当前用户
Long userId = UserHolder.getUser().getId();
String key1 = "follows:" + userId;
String key2 = "follows:" + followUserId;
//2、求交集(前提是双方都把自己关注的对象存储到set集合中了)
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
if (intersect.isEmpty() || intersect.size() == 0)
return Result.ok();
//3、取出共同关注的对象的ID
List<Long> ids = intersect
.stream()
.map(Long::valueOf)
.collect(Collectors.toList());
7、地理位置应用GEO
String key = RedisConstants.SHOP_GEO_KEY + typeId;
//3、查询redis、按照距离排序、分页。 结果:shopId,distance
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeCoordinates().limit(end)
);
通过调用 opsForGeo() 方法获取 RedisTemplate 中的 Geo 操作对象,然后使用 search() 方法进行地理位置搜索。其中,GeoReference.fromCoordinate(x, y) 表示搜索以坐标 (x, y) 为中心,半径为 5000 米的范围内的数据;new Distance(5000) 表示搜索的半径为 5000 米;RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeCoordinates().limit(end) 表示搜索结果需要包含坐标,并且限制最多返回 end 条记录。最后,调用 search() 方法返回的结果是一个 GeoResults 对象,其中包含了按照距离排序后的店铺 ID 和与中心点的距离(单位为米)。
8、滚动分页
利用zset集合的分数实现滚动分页
public IPage<User> recommend(Integer pageNum, Integer pageSize, HttpServletRequest request) {
User loginUser = getLoginUser(request);
String key = String.format("user:recommend:%s", loginUser.getId());
long minScore = (pageNum - 1) * pageSize;
long maxScore = pageNum * pageSize - 1;
// 从缓存中获取指定分数范围内的数据,按分数从高到低排序
Set<ZSetOperations.TypedTuple<Object>> typedTuples = redisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, minScore, maxScore);
if (!typedTuples.isEmpty()) { // 缓存命中,直接返回缓存数据
return parseResult(typedTuples); //parseResult方法是一个将typedTuples解析成想要的数据
}
// 无缓存,查询数据库
Page<User> page = new Page<>(pageNum, pageSize);
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
IPage<User> userPage = page(page, queryWrapper);
// 将查询结果存入缓存,使用与查询时相同的分数范围
int count = 0;
for (User user : userPage.getRecords()) {
count++;
if (count >= 10 && minScore == 0){
minScore += 10;
}
redisTemplate.opsForZSet().add(key, user, minScore); // 使用最小分数存入缓存,每10个分成了一组的数据
}
redisTemplate.expire(key, 30, TimeUnit.MINUTES);
return userPage;
}
9、每月签到
BitMap实现获取本月签到次数
// 用户签到
public void signIn(String userId, String date) {
String key = "signin:" + date; // 每天一个 key,用于记录当天的签到情况
redisTemplate.opsForValue().setBit(key, Long.parseLong(userId), true);
}
// 判断用户是否已签到
public boolean hasSignedIn(String userId, String date) {
String key = "signin:" + date;
return redisTemplate.opsForValue().getBit(key, Long.parseLong(userId));
}
// 获取本月总签到次数
public long getTotalSignInCountThisMonth() {
LocalDate now = LocalDate.now();
int daysInMonth = now.lengthOfMonth();
long totalSignInCount = 0;
for (int day = 1; day <= daysInMonth; day++) {
String key = "signin:" + now.format(DateTimeFormatter.ofPattern("yyyyMM")) + String.format("%02d", day);
totalSignInCount += redisTemplate.opsForValue().bitCount(key);
}
return totalSignInCount;
}
10、用户唯一标识
一共有3种方法实现:
-
数据库自增
-
UUID
-
redis实现
通过位运算符将时间戳和序列号合并成一个 64 位的二进制数,其中时间戳占据高 32 位,序列号占据低 32 位。
public class RedisIdWorker {
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
private static final long BEGIN_TIMESTAMP = 1702762344L;
private static final int COUNT_BITS =32;
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;
}
}