项目需要用到的编程知识
Redis Stream
Redis Stream | 菜鸟教程 (runoob.com)
短信登录功能实现
首先,为什么要使用 Redis 来存储 Session 呢?
首先一个服务器只有一个 Session,但是在分布式的环境下,各个服务器之间的 Session 就无法实现共享,且一但服务器重启的话 Session 也会失效。所以我们需要将 Session 存储到 Redis 中。
下面讲解一些重点部分内容以及实现:
这里虽然提到共享 session 但是这里没有使用到 session 来存储用户数据,而是用 key + token : user_info 的形式来存储到 Redis 中的。session 只是存储 token 然后去 Redis 中查询数据。
1.手机号码登录功能
- 先校验验证码是否正确。
- 如果验证码正确,在数据库中查询用户。
- 用户不存在,创建一个新用户加入数据库中。
- 生成随机 token 作为登录令牌,用于登录校验。使用 key + token 作为 Redis 的 key,将用户对象存储到 Redis 中,用于后面登录的验证和用户信息获取。并将 token 返回给前端。
@Override
public Result phoneLogin(LoginFormDTO loginForm, HttpSession session) {
// 校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号码格式错误");
}
// 校验验证码
Object cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)) {
return Result.fail("验证码错误");
}
// 根据手机号查询用户 : select * from tb_user where phone = ?
User user = query().eq("phone",phone).one();
// 判断用户是否存在
if(user == null) {
// 用户不存在, 创建一个新用户, 这里相当于注册
user = createNewUser(phone);
}
// 将用户信息保存到 Redis 中, 随机生成 token, 作为登录令牌
String token = UUID.randomUUID().toString();
// 将 User 对象转换为 HashMap 进行存储
// BeanUtil.copyProperties(user, UserDTO.class) : 将 User 对象转换为一个 UserDTO 的对象
UserDTO dto = BeanUtil.copyProperties(user, UserDTO.class);
// 将 UserDto 转换为哈希表, 属性字段作为 key, 属性值作为 value
// 将字段的所有值都转换为 String 才能存入 StringRedisTemplate 中
// Map 的 key 是 UserDTO 的字段,而 value 则是 UserDTO 的值
Map<String, Object> userMap = BeanUtil.beanToMap(dto, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 将 token 存储到 Redis 中, 值为用户信息的哈希表
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 设置 token 的有效期为 30 分钟
stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 将 token 返回给前端
return Result.ok(token);
}
前端先将返回的 token 存入到 Session 中,通过 axios 请求拦截器让接下来的请求头都带上 token ,通过拦截器完成登录校验 (该代码位于 vue 的 main.js 中)。后面通过拦截器获取到请求头中的 token 判读用户是否登录.
login(){
if(!this.form.phone || !this.form.code){
this.$message.error("手机号和验证码不能为空!");
return
}
axios.post("/user/phone-login", this.form)
.then(({data}) => {
if(data){
// 保存用户信息到 session
sessionStorage.setItem("token",data.data);
console.log("login:" + sessionStorage.getItem("token"));
}
// 跳转到个人中心页面
this.$router.push("/center")
})
.catch(err => this.$message.error(err))
}
// 添加请求拦截器
axios.interceptors.request.use(
(config) => {
const token = sessionStorage.getItem('token'); // 从 sessionStorage 中获取 token
if (token) {
config.headers['authorization'] = token; // 给请求头新增一个字段 authorization, 添加 token 到请求头中
} else {
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
2.拦截器功能实现
总共有两个拦截器,第一个拦截器执行完执行第二个拦截器。
第一个拦截器的作用主要是刷新登录的过期时间,通过获取 token 重置 Redis 中的登录记录过期时间, 并将数据信息保存带 ThreadLocal 中。
public class RefreshTokenInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取到请求头中的 token
String token = request.getHeader(SystemConstants.USER_LOGIN_TOKEN);
if(StrUtil.isBlank(token)) {
return true; // 直接放行,到第二个登录拦截器处理拦截
}
// 基于 token 获取 redis 中的用户
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
// .entries 的作用是获取哈希中的所有键值对
Map<Object,Object> userMap = stringRedisTemplate.opsForHash()
.entries(tokenKey);
// 判断用户是否存在
if(userMap.isEmpty()) {
return true; // 直接放行
}
// 将查询到的 Hash 数据转化为 UserDTO 对象
UserDTO dto = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 将用户信息保存到 ThreadLocal
UserHolder.saveUser(dto);
// 刷新 token 有效期
stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 放行
return true;
}
/* 该方法在请求处理完之后被调用, 无论是正常还是异常现象 */
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 将用户从 ThreadLocal 中移除
UserHolder.removeUser();
}
}
第二个拦截器作用主要是判断 ThreadLocal 中是否有用户对象,以此来拦截未登录情况下未被放行的请求。
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// TODO 从 ThreadLocal 中获取用户, 如果没有则拦截
if(UserHolder.getUser() == null) {
response.setStatus(401);
return false;
}
// TODO 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// TODO 将用户从 ThreadLocal 中移除
UserHolder.removeUser();
}
}
3.ThreadLocal 实现同一浏览器多个页面多个用户登录
首先一个浏览器只有一个 Session,将用户信息只是存储在 Session 的话,在多线程情况下会产生问题,所以可以采用 ThreadLocal 来存储每个线程所拥有的变量。
首先我们的用户每打开一个浏览器页面操作,都是一个单线程操作,而 ThreadLocal 都存储的是当前线程的变量。当用户进行登录操作后。拦截器会使用从 Session 获得的 token,使用 key + token 获取到 Redis 中的用户对象,并将其存入到 ThreadLocal 中。接下来该线程获取的用户对象就直接从 ThreadLocal 中获取,这样的话多个用户之间的内容就不会冲突了。
package com.hmdp.utils;
import com.hmdp.dto.UserDTO;
public class UserHolder {
// ThreadLocal 是 Java 中的一个类,用于创建线程本地变量。
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
优惠券秒杀功能实现
1.Lua 脚本实现对 Redis 的原子性操作(下单和扣减库存操作)
lua脚本执行流程图:主要是对 Redis 的秒杀相关操作,lua脚本可以保证操作的原子性,解决了 Redis 的命令不是原子性的问题。
-- 1. 参数列表,参数在 seckillVoucher() 函数内调用后传入
-- 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 (lua 脚本连接字符串 ..)
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单 key
local orderKey = 'seckill:order:' .. userId
--3. lua 脚本业务
--3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
-- 3.2 库存不足, 返回 1
return 1
end
-- 3.2 判断用户是否已经下单
-- sismember <key><value> 判断集合 <key> 是否为含有该 <value> 值,有 1,没有 0
if(redis.call('sismember',orderKey,userId) == 1) then
-- 3.3 重复下单, 返回 2
return 2
end
-- 3.4 扣减库存
redis.call('incrby', stockKey, -1)
-- 3.5 下单 (保存用户) Redis 中的 set 可以将一个或多个元素存储在集合 key 中
-- 这里是将用户 id 和 订单 id 保存到 orderKey 的集合中
redis.call('sadd', orderKey, userId, orderId)
-- 3.6 将消息发送到消息队列中, XADD stream.orders * k1 v1 k2 v2 ......
-- 注意这里要写 id , 因为最后是要存入数据库的, 数据库表字段是 id
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0
优惠券秒杀过程执行流程图
- 先获取到当前的用户 id 和订单 id。
- 传入参数开始执行 lua 脚本操作 Redis,返回 0 表示执行成功,订单已经被加入到 Stream 消息队列中。
- 给代理对象赋值,并且返回订单 id 给前端。
秒杀优惠券代码实现:
/**
* 秒杀优惠券
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 获取当前用户 id
Long userId = UserHolder.getUser().getId();
// 获取订单 id
Long orderId = redisWorker.generateId("order");
// 执行 lua 脚本, 进行判断, 削减库存和将订单信息加入消息队列操作
// 消息加入到消息队列后消息队列就会开始消费,执行创建订单等一系列操作
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // 这里传入一个空的列表,不要传 null
voucherId.toString(),userId.toString(),orderId.toString() // 传入三个参数
);
// 判断结果是否为 0
int res = result.intValue();
if(res != 0) {
// 不为 0,为 1 是库存不足, 否则是用户已经下单
return Result.fail(String.valueOf(res));
}
// 获取到代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 返回订单 id
return Result.ok(orderId);
}
这里具体讲一下异步下单的一个流程:
首先这里先讲解一下为什么要使用消息队列来处理我们的异步订单。
- 消息队列有持久化的功能,假如系统在运行中突然崩溃,此时消息队列中还可以暂存消息,等系统恢复后再进行处理。
- 消息队列的确认机制,每处理完一条消息都会有确认通知。这样就保证了消息的可靠性,只有在确认处理后才会被删除。
要注意的是,一旦消息被确认,它并不会被立即从Stream中删除,而是会在后台根据配置的规则进行自动修剪(trimming)。修剪可以通过配置Stream的最大长度或最大时间来执行。一旦Stream的长度达到或超过指定的最大长度,或者消息的时间戳超过指定的最大时间,Redis会自动删除一些消息以保持Stream的大小在可接受范围内。
2. 单线程实现对 Stream 消息队列中任务的消费
- 从消息队列中获取消息,并转化为 VoucherOrder 对象。
- 调用 handleVoucherOrder() 方法来处理订单。
- 对消息队列消息进行确认。
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
try {
while (true) {
// 获取消息队列中的消息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STRAMS stream.orders >
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()) //
);
// 判断消息是否获取成功
if(list == null || list.isEmpty()) {
// 获取失败, 说明没有消息, 则继续循环
continue;
}
// 解析消息中的订单信息
MapRecord<String,Object,Object> record = list.get(0);
Map<Object,Object> values = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
// 获取成功, 下单
handleVoucherOrder(voucherOrder);
// ACK 确认 SACK stream.orders g1 id
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
}
}catch (Exception e) {
log.error("消息队列读取出现异常:" + e);
// 处理 PendingList 队列
handlePendingList();
}
}
}
如果在处理消息的时候出现异常,消息会进入 PendingList 队列,根据同样的方式处理消息并确认。
3. 实现对数据库的库存扣减和下单操作
注意这里是异步下单操作数据库的过程,操作 Redis 已经由 Lua 脚本完成了,接下来的功能主要是实现数据库的扣减库存和新增订单操作。
- 通过 VoucherOrder 对象获取用户 id。此时不能用 ThreadLocal 实现的 UserHolder.getUserId(); 来获取 id 了,因为这个操作是由子线程操作的。
- 使用 Redission 创建锁对象。
- 这里讲一下 Redission 是如何加锁的,首先使用 ReddisionClient 获取 RLock 对象,对象用用户 ID 保证唯一性。
- 使用 tryLock() 尝试获取锁。
- false 表示获取成功,否则获取失败。
- 最后再使用 unlock 方法释放锁。
- 尝试获取锁对象。失败打印错误日志。
- 获取锁对象成功,通过代理对象调用创建订单函数 createVoucher() 创建订单。
/**
* 用来处理订单
* @param voucherOrder
*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// Can't use this way to get the userId, because there is not the main thread but a child thread
/* Long userId = UserHolder.getUser().getId(); // 获取当前用户 id */
Long userId = voucherOrder.getUserId();
// 创建锁对象
// SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId,stringRedisTemplate);
// 这里改用 Redisson 创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 尝试获取锁 ( 没有参数表示如果获取不到直接失败 )
boolean lock = redisLock.tryLock();
// 这里应该打印完错误日志后 Return 才对??
if(!lock) { // 获取锁失败, 直接返回错误信息
log.error("不允许重复下单");
}
// This locking method does not work in distributed or clustered environment
/*
// 给每个用户 ID 进行加锁, 只有相同用户才会阻塞
// intern() 保证是安装字符串的值来进行加锁的, 去字符串线程池查找有没有相同值的字符串
synchronized (userId.toString().intern()) {
// 这里存在事务失效的问题, 需要用到代理对象调用该方法
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 返回订单 id
return proxy.createVoucherOrder(voucherId);
}
*/
try {
// Can't get proxy here like this, because now is a child thread but not a main thread,so init it in seckillVoucher(Long voucherId) method
/*
这里存在事务失效的问题, 需要用到代理对象调用该方法
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); */
// 这边按道理应该需要一个 Catch,如果 Proxy 没有被初始化就会有空指针异常 ????
proxy.createVoucherOrder(voucherOrder); // 通过代理对象调用创建订单方法
} finally {
// 释放锁
redisLock.unlock();
}
}
3.1 加锁问题分析
- 为什么要加锁:加锁主要是为了解决超卖问题和一人一单的校验问题。
超卖问题:
超卖问题即当我们去执行扣减库存的时候是多线程操作的,而线程有两步需要执行,分别是判断库存是否大于 0,扣减库存。假如库存为 1,那么当第一个线程还没有执行扣减库存操作的时候,第二个线程此时判断库存是大于 0,也进行了扣减库存操作,这样就会出现超卖问题。
一人一单问题:
也以我们的代码为例子,当我们抢购一个订单后,会在数据库中创建一条订单记录,证明该用户已经抢购过该订单,避免重复下单。但在多线程环境下,线程 1 还没有进行插入订单记录的时候,此时线程 2 来判断是否已经下单,由于数据库中没有该记录,就会造成重复下单的问题。
- 为什么使用 Redis 作为分布式锁
主要是为了解决在分布式和集成环境下 JVM 自带的锁不起作用的问题。
当在集成环境下的时候,JVM 的锁监视器只能监视单个环境下的锁,在集群环境下还是有线 程安全问题。
- 为什么使用 Redission 来实现 Redis 分布式锁
传统的 Redis 实现分布式锁主要是通过 setnx 的互斥功能实现的,setnx 有以下几个问题:
- 锁的不可重入。
- 锁的不可重试。
- 锁的超时释放:即虽然超时释放可以避免死锁,但是也考虑到某一些业务的执行时间过长导致提前释放,这里主要说的是锁的误删的问题。
4. createVoucher() 详解(创建订单函数,在 handleVoucherOrder() 中被调用)
- 根据 voucher_id 和 user_id 在优惠券订单表中查询是否已经抢购过了。
- 扣减数据库库存。
- 将该优惠券订单信息加入优惠券订单表。
/**
* 用来创建订单对象
* @param voucherOrder
*/
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
// TODO 实现一人一单功能, 即每个用户只能抢购一次
Long userId = voucherOrder.getUserId(); // 这里是子线程,不能用 UserHolder 获取
int count = query().eq("voucher_id",voucherOrder.getVoucherId()).eq("user_id",userId).count();
if(count >= 1) {
log.error("当前用户已经抢购了");
return;
}
// TODO 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId())
// NOTE 这里用 stock 当作版本号, 相当于实现了乐观锁, 只需要库存大于 0 即可以修改
.gt("stock",0)
.update();
if(!success) {
log.error("库存不足");
return;
}
save(voucherOrder);
}
点赞功能实现
1. 点赞功能实现
- 获取到当前用户 id。
- 通过存储在 Redis 中的点赞记录判断该用户是否已经点赞过该笔记。
- 如果用户没有点赞,数据库点赞数 +1,将点赞记录保存到 Redis 的 Zset 集合。
- 如果用户已经点赞过,数据库点赞数 -1,将点赞记录从 Redis删除。
@Override
public Result likeBlog(Long id) {
// TODO 获取当前用户 id
Long userId = UserHolder.getUser().getId();
// TODO 判断当前用户是否已经点赞
String key = RedisConstants.BLOG_LIKED_KEY + id;
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 当前用户没有点过赞
if(score == null) {
// 数据库点赞数 + 1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
if(!isSuccess) {
return Result.fail("数据库点赞 + 1 操作失败");
}
// 将点赞记录保存到 Redis 的集合
// System.currentTimeMillis(): 这是成员的分数。在有序集合中,每个成员都有一个分数,用于确定成员的排名顺序
stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
} else { // 当前用户已经点赞
// 数据库点赞数量 - 1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
if(!isSuccess) {
return Result.fail("数据库点赞 - 1 操作失败");
}
// 清楚 Redis 中的点赞记录
stringRedisTemplate.opsForZSet().remove(key,userId.toString());
}
return Result.ok();
}
使用 Zset 存储元素主要是为了后面实现点赞排行榜的功能。给点赞记录进行排序。这里我们使用当前的时间戳作为分数进行排序,时间越早排在越前。
2. 点赞排行榜功能
- 根据 key 从 Zset 中取出前五个点赞记录 (使用了 Zset 的 range 方法)。
- 使用 stram 流操作将 Set<String> 转化为 List<Long> 。
- 将 List<Long> 转换为用逗号分割的字符串 ids。
- 通过 ids 查询查询每个用户对象,并将其转换为 UserDTO 对象。
- 最后将得到的 UserDTO 对象返回即可。
@Override
public Result queryBlogLikes(Long id) {
// TODO 查询该博客的用户 id 前五个
Set<String> topFive = stringRedisTemplate.opsForZSet().range(RedisConstants.BLOG_LIKED_KEY + id,0,4);
if(topFive == null) {
return Result.ok();
}
// TODO 将其转换为 Long 整数列表
// .collect(Collectors.toList()): 这是一个终结操作, 它将流中的元素收集到一个新的 List 中
List<Long> ids = topFive.stream().map(Long :: valueOf).collect(Collectors.toList());
// NOTE 注意这里不能把空数组传入 userService.listByIds(ids)
if(ids.size() == 0) {
return Result.ok();
}
// 将 id 用逗号分割并转换为一个字符串
String idStr = StrUtil.join(",",ids);
// TODO 根据用户 id 查询用户
// NOTE 这里不能用原来的 listByIds 去查询, listByIds 不是按顺序查询的, 需要自己实现 SQL 语句
List<UserDTO> users = userService.query().in("id",ids)
.last("ORDER BY FIELD(id," + idStr + ")").list()
// .map(user -> BeanUtil.copyProperties(user, UserDTO.class)): 这是一个流操作,它将每个用户对象转换为 UserDTO 对象。
// 在这里使用了一个 lambda 表达式,对每个用户对象执行了 BeanUtil.copyProperties 方法,将用户对象的属性复制到一个新的 UserDTO 对象中
.stream()
.map(user -> BeanUtil.copyProperties(user,UserDTO.class))
.collect(Collectors.toList());
// TODO 返回用户数据
return Result.ok(users);
}
关注功能实现
1.关注博主功能实现
- 获取当前用户的 id。
- 如果用户没有关注:
- 创建新的 Follow 对象并赋值,将其存入到数据库中。
- 然后通过 key 将 followUserId( 被关注用户 id ) 存入 Redis 集合中。
- 如果用户已经关注:
- 通过 user_id 和 follow_user_id 将关注记录从数据库中移除。
- 通过 key 将 Redis 中的关注记录也移除。
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// TODO 获取到当前用户
UserDTO user = UserHolder.getUser();
// TODO 判断当前用户是否已经被关注
if(!isFollow) {
// 没有关注,新增关注数据
Follow follow = new Follow();
// 设置用户 id 和被关注用户 id
follow.setUserId(user.getId());
follow.setFollowUserId(followUserId);
// 将新增数据加入数据库中
boolean isSuccess = save(follow);
String key = RedisConstants.FOLLOWS_KEY + user.getId();
// TODO 将关注用户数据保存到 Redis 中
if(isSuccess) {
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 已经关注,取消关注
// QueryWrapper<Follow>():这是一个查询包装器, 用于构建数据库查询条件
boolean isSuccess = remove(new QueryWrapper<Follow>().eq("user_id",user.getId()).eq("follow_user_id",followUserId));
// TODO 将关注数据从 Redis 中移除
String key = RedisConstants.FOLLOWS_KEY + user.getId();
if(isSuccess) {
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}
2.关注推送功能实现
- 如果用户已经关注某个博主,当该博主发送博客的时候,会将该博客推送给所有关注他的粉丝。
- 通过 user_id 查询到所有关注该博主的关注记录。
- 通过循环遍历关注记录获取粉丝用户 id。
- 通过 FEED_KEY + 粉丝用户 id 作为 key 将博客 id 加入到 Zset 中。按照时间进行排序。(后续也是通过这个 key 来查询推送的博客)。
@Override
public Result saveBlog(Blog blog) {
// TODO 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// TODO 保存探店博文
boolean isSuccess = save(blog);
if(!isSuccess) {
return Result.fail("保存探店笔记失败");
}
// TODO 查询该笔记作者的所有关注记录
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
// TODO 推送该笔记给所有粉丝
for(Follow follow : follows) {
// 获取粉丝 id
Long userId = follow.getUserId();
// 推送笔记给粉丝(Redis)
String key = RedisConstants.FEED_KEY + userId;
// 按时间排序,所以加入 System.currentTimeMillis()
stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
}
// 返回 id
return Result.ok(blog.getId());
}
3.查询推送的博客功能实现
在将推送功能实现之前,先讲一下如何实现滚动查询。
首先 Zset 中有六条数据如图所示
当我们逆序查询 0 - 2 条数据的时候,结果是 6,5,4,当我们下次查询的时候期望的数据应该是3,2,1。
就在这个时候突然插入一条数据 7,当我们再次查询的时候数据就变成了4,3,2,显然不是我们需要的数据。这个时候就不能用角标进行查询了,需要修改代码。
为了实现滚动查询,我们不使用 ZREVRANGE 而是使用 ZREVRANGEBYSCORE,即用分数最大值和最小值进行查询。
开始时候我们设置最大值为 1000,最小值为 0,查询前三条数据。
第二次查询前插入第新数据 8。
再次查询时,分数最大值设置为上一次查询的分数最小值,最小值仍然为 0,因为查询结果仍包含上一次分数最小值,所以偏移量要设置为 1。这样得到的查询结果就是我们需要的结果。
但是还是有一个问题,就是当存在分数相同的时候,我们的 offset(偏移量)就不能单单只设置为 1 了。
如下图所示,如果偏移量还是 1 还是会查找到重复的 m6,所以此时的偏移量应该是 2 才对。即 offset 应该为上一次查询结果中与最小值相同的元素个数。
总结下来,滚动查询重要的两个参数的计算方法为:
- offset:0(第一次) | 与上一次查询最小值值相同的元素个数。
- max: 时间戳最大值(第一次查询) | 上一次查询的最小值。
在理解完什么是滚动查询后,接下来实现博客推送查询功能就很简单啦。下面是实现代码。
- 首先获取当前用户 id。
- 使用 ZREVRANGEBYSCORE 查询到对应的结果,赋值给Set<ZSetOperations.TypedTuple<String>>(这是一个元组,存储的是值和分数,值的类型为 String)。
- 遍历 Set<ZSetOperations.TypedTuple<String>>,统计最小时间和偏移量。
- 根据博客 id 到数据库中查询博客。
- 将内容封装成 ScrollResult 对象后返回。
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// TODO 获取当前用户 id
Long userId = UserHolder.getUser().getId();
// TODO 查询当前用户收件箱 ZREVRANGEBYSCORE key Max(上一次的最小值) Min LIMIT offset count
String key = RedisConstants.FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples =
// 关键字为 key, 最小值为 0, 最大值为 max, 偏移量为 offset, 每页数量为 3
stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0,max,offset,3);
// 如果得到的集合为空,直接返回即可
if(typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// TODO 对集合数据进行解析
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0; // 最小时间
int os = 1; // 偏移量
for(ZSetOperations.TypedTuple<String> tuple : typedTuples) {
ids.add(Long.valueOf(tuple.getValue())); // 获取 id
long time = tuple.getScore().longValue(); // 获取时间戳 (score)
// TODO 统计要返回给前端的偏移量
if(time == minTime) {
os++;
} else {
minTime = time;
os = 1;
}
}
// TODO 根据 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) {
// TODO 查询博客相关用户信息
queryUserById(blog);
// TODO 判断博客是否被点赞过, 即对 isLiked 进行初始化
isBlogLiked(blog);
}
// TODO 封装并返回
ScrollResult result = new ScrollResult();
result.setList(blogs);
result.setOffset(os);
result.setMinTime(minTime);
return Result.ok(result);
}