3. Redis应用
3.1 短信登录
3.1.1 session短信登录
- 验证码存储
@Override
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号,不存在返回错误信息
if (RegexUtils.isPhoneInvalid(phone)){
// 不符合返回错误信息
return Result.fail("手机号格式不正确");
}
// 2.生成验证码
String code = RandomUtil.randomNumbers(6);
// 3.保存验证码存入session
session.setAttribute("code",code);
// 4.发送验证码
log.debug("发送短信验证码成功,验证码为{}",code);
// 5.返回OK
return Result.ok();
}
- 用户登录验证
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)){
// 不符合返回错误信息
return Result.fail("手机号格式不正确");
}
// 2. 校验验证码
String cacheCode = (String) session.getAttribute("code");
String code = loginForm.getCode();
if (StringUtils.isEmpty(cacheCode) || !cacheCode.equals(code)){
// 3. 验证码不一致,报错
return Result.fail("验证码错误");
}
// 4. 判断用户是否存在
User user = query().eq("phone", phone).one();
// 5. 不存在则创建用户并保存数据库
if (user == null){
user = createUserWithPhone(phone);
}
// 6. 把登录信息存入session当中
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
session.setAttribute("user",userDTO);
return Result.ok();
}
-
拦截器配置
3.1 编写拦截类
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 获取session HttpSession session = request.getSession(); // 2. 根据session获取用户 Object user = session.getAttribute("user"); // 3. 判断用户是否存在 if (user == null){ // 4. 不存在就拦截 response.setStatus(401); throw new RuntimeException("用户没有登录"); } // 5. 存在就保存到ThreadLocal当中 UserHolder.saveUser((UserDTO) user); // 6. 放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
3.2 注册拦截器
@Configuration public class MvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login" ); } }
3.1.2 Redis短信登录
session短信登录存在的问题,当多台tomcat部署的时候,每台tomcat都会有自己的session,由于负载均衡会轮询多台tomcat,会导致数据丢失问题。例如:验证码存在第一天tomcat上,而登录是访问的是第二天tomcat上,这样无法找到验证码信息。
- 验证码存储
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号,不存在返回错误信息
if (RegexUtils.isPhoneInvalid(phone)){
// 不符合返回错误信息
return Result.fail("手机号格式不正确");
}
// 2.生成验证码
String code = RandomUtil.randomNumbers(6);
// 3.保存验证码存入redis,要保证key唯一,所以使用phone作为key,并设置过期时间为2分钟
redisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 4.发送验证码
log.debug("发送短信验证码成功,验证码为{}",code);
// 5.返回OK
return Result.ok();
}
- 用户登录验证
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)){
// 不符合返回错误信息
return Result.fail("手机号格式不正确");
}
// 2. 校验验证码,从redis当中取验证码校验
String cacheCode = redisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
String code = loginForm.getCode();
if (StringUtils.isEmpty(cacheCode) || !cacheCode.equals(code)){
// 3. 验证码不一致,报错
return Result.fail("验证码错误");
}
// 4. 判断用户是否存在
User user = query().eq("phone", phone).one();
// 5. 不存在则创建用户并保存数据库
if (user == null){
user = createUserWithPhone(phone);
}
// 6.将登录信息保存到Redis当中
// 6.1 随机生成 token作为key
String token = UUID.randomUUID().toString(true);
// 6.2 将user对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().ignoreNullValue().setFieldValueEditor((fieldName, fieldValue)->fieldValue.toString()));//为了解决Inter->String 的问题
// 6.3 存入Redis并设置过期时间
String key = LOGIN_USER_KEY + token;
redisTemplate.opsForHash().putAll(key,userMap);
redisTemplate.expire(key,LOGIN_USER_TTL,TimeUnit.MINUTES);
// 7.返回token给前端
return Result.ok(token);
}
-
配置拦截器
3.1 配置刷新缓存时间拦截器
private StringRedisTemplate redisTemplate; public RefreshInterceptor(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 获取请求头中的token String token = request.getHeader("authorization"); if (StringUtils.isEmpty(token)){ // 2. 不存在就交给登录拦截器 return true; } // 3. 基于token获取用户 String key = LOGIN_USER_KEY + token; Map<Object, Object> userMap = redisTemplate.opsForHash().entries(key); if (userMap.isEmpty()){ // 4. 不存在就交给登录拦截器 return true; } UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); // 5. 存在就保存到ThreadLocal当中 UserHolder.saveUser(userDTO); // 6. 刷新token有效期 redisTemplate.expire(key,LOGIN_USER_TTL, TimeUnit.MINUTES); // 7. 放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); }
3.2 配置登录拦截器
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 从ThreadLocal获取用户 UserDTO userDTO = UserHolder.getUser(); if (userDTO == null){ // 2. 如果ThreadLocal中没有用户,那就拦截 throw new RuntimeException("用户未登录"); } return true; }
3.2 缓存应用
3.2.1 添加缓存
@Override
public Result queryById(Long id) {
// 1. 从redis中查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (!StringUtils.isEmpty(shopJson)) {
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,根据id查询数据库
Shop shop = getById(id);
// 5. 不存在,返回错误
if (shop == null) {
return Result.fail("商品不存在");
}
// 6. 存在,存入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
待完成 对商品分类列表进行缓存处理
3.2.2 缓存更新策略
3.2.3 实现缓存读写一致
查询数据
@Override
public Result queryById(Long id) {
// 1. 从redis中查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (!StringUtils.isEmpty(shopJson)) {
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,根据id查询数据库
Shop shop = getById(id);
// 5. 不存在,返回错误
if (shop == null) {
return Result.fail("商品不存在");
}
// 6. 存在,存入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
更新数据
@Override
@Transactional // 事务是为了保证Redis和MySQL的原子性
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id == null){
return Result.fail("店铺不存在");
}
// 1. 更新数据库
updateById(shop);
// 2. 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
return Result.ok();
}
3.2.4 缓存穿透
缓存穿透是指:大量请求缓存和数据库中不存在的数据,导致数据库压力过大。
使用空对象实现,防止缓存穿透
@Override
public Result queryById(Long id) {
// 1. 从redis中查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StringUtils.hasText(shopJson)) {
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 判断是否为空值
if (shopJson != null){
return Result.fail("系统繁忙,请重试");
}
// 5. 不存在,根据id查询数据库
Shop shop = getById(id);
// 6. 不存在,返回错误
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("商品不存在");
}
// 7. 存在,存入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
3.2.5 缓存雪崩
3.2.6 缓存击穿(热点key问题)
互斥锁解决缓存击穿
public Shop queryWithMutex(Long id) {
// 1. 从redis中查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StringUtils.hasText(shopJson)) {
// 3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 4. 判断是否为空值
if (shopJson != null) {
return null;
}
// 5. 实现缓存重建
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
// 5.1 获取互斥锁
boolean isLock = tryLock(lockKey);
// 5.2 判断是否获取成功
if (!isLock) {
// 5.3 失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 5.4 成功,再次查询缓存,有可能在加锁的时候,别的线程已经重建缓存了
shopJson = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.hasText(shopJson)) {
// 3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 5.5 别的线程没有重建缓存,查询数据库
Thread.sleep(100);
shop = getById(id);
// 6. 不存在,返回错误
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 7. 存在,存入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 8. 释放互斥锁
unLock(lockKey);
}
return shop;
}
逻辑删除实现缓存击穿
public Shop queryWithLogicalExpire(Long id) {
// 1. 从redis中查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StringUtils.isEmpty(shopJson)) {
// 3. 不存在,直接返回
return null;
}
// 4. 命中,把json反序列化成对象
RedisData redisData;
redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject jsonObject = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(jsonObject, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 未过期,直接返回店铺信息
return shop;
}
// 5.2 已过期,需要缓存重建
// 6. 进行缓存重建
// 6.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
// 6.2 判断是否获取锁成功
boolean isLock = tryLock(lockKey);
if (isLock) {
// 再次检测
shopJson = stringRedisTemplate.opsForValue().get(key);
redisData = JSONUtil.toBean(shopJson, RedisData.class);
jsonObject = (JSONObject) redisData.getData();
shop = JSONUtil.toBean(jsonObject, Shop.class);
expireTime = redisData.getExpireTime();
// 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回店铺信息
return shop;
}
// 6.3 成功开启新线程执行缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
// 重建缓存
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放所
unLock(lockKey);
}
});
}
return shop;
}
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1. 查询店铺信息
Shop shop = getById(id);
Thread.sleep(50);
// 2. 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
3.2.7 缓存工具类
public class CacheClientUtils {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClientUtils(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 存储任意对象至redis,解决了缓存穿透问题
* @param key redis中的键
* @param value redis当中的值
* @param time 过期时间
* @param unit 过期时间的单位
*/
public void setWithPassThrough(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 存储任意对象至redis,解决了缓存击穿问题
* @param key redis中的键
* @param value redis当中的值
* @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)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 在缓存穿透的情况下,查询数据
* @param keyPrefix key的前缀
* @param id 查询的id
* @param type 查询对象的类型
* @param dbFallback 查询方法
* @param time 过期时间
* @param unit 过期时间单位
* @return
*/
public <R,ID> R queryWithPassThough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
// 1. 从redis中查询商铺缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(json)) {// 3. 存在,直接返回
return JSONUtil.toBean(json, type);
}
// 4. 判断是否为空值
if (json != null) {
return null;
}
// 5. 不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 6. 不存在,返回错误
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", time, unit);
return null;
}
// 7. 存在,存入redis
this.setWithPassThrough(key,r,time,unit);
return r;
}
/**
* 热点key问题,在缓存击穿的情况下,查询数据
* @param keyPrefix key的前缀
* @param id 查询的id
* @param type 查询对象的类型
* @param dbFallback 查询方法
* @param time 过期时间
* @param unit 过期时间单位
*/
public<R,ID> R queryWithLogicalExpire(String keyPrefix,ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
// 1. 从redis中查询商铺缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isBlank(json)) {
// 3. 不存在,直接返回
return null;
}
// 4. 命中,把json反序列化成对象
RedisData redisData;
redisData = JSONUtil.toBean(json, RedisData.class);
JSONObject jsonObject = (JSONObject) redisData.getData();
R r = JSONUtil.toBean(jsonObject, 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;
// 6.2 判断是否获取锁成功
boolean isLock = tryLock(lockKey);
if (isLock) {
// 再次检测
json = stringRedisTemplate.opsForValue().get(key);
redisData = JSONUtil.toBean(json, RedisData.class);
jsonObject = (JSONObject) redisData.getData();
r = JSONUtil.toBean(jsonObject, type);
expireTime = redisData.getExpireTime();
// 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回店铺信息
return r;
}
// 6.3 成功开启新线程执行缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
// 重建缓存
try {
R r1 = dbFallback.apply(id);
this.setWithLogicalExpire(key, r1, time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放所
unLock(lockKey);
}
});
}
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
}
3.3 秒杀应用
3.3.1 全局ID
@Component
public class RedisIdWorker {
private static final long BEGIN_TIMESTAMP = 1640995200L;
private final StringRedisTemplate stringRedisTemplate;
private final static Long COUNT_BITS = 32L;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 生成全局ID
* @param keyPrefix 键前缀
* @return
*/
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("yyyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
return timestamp<<COUNT_BITS|count;
}
}
3.3.2 秒杀优惠券
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = voucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀尚未开始
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已经结束
return Result.fail("秒杀尚未开始");
}
// 4. 判断库存是否充足
if (voucher.getStock()<1) {
// 库存不足
return Result.fail("秒杀券已抢完");
}
// 5. 扣除库存
boolean success = voucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).update();
if (! success){
// 扣除失败
return Result.fail("秒杀券已抢完");
}
// 6. 创建订单
// 6.1 订单id
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7. 返回订单id
return Result.ok();
}
如果只是采用普通的方式,会有超卖的问题发生,总共只有100个票,但是有200个线程去并发访问。按照道理来说,异常率应该是50%,而此时小于了50%,所以出现了超卖现象。
3.3.3 超卖问题解决(乐观锁实现)
乐观锁只能针对与更新操作,对于插入操作无法使用乐观锁。
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = voucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀尚未开始
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已经结束
return Result.fail("秒杀尚未开始");
}
// 4. 判断库存是否充足
if (voucher.getStock()<1) {
// 库存不足
return Result.fail("秒杀券已抢完");
}
// 5. 扣除库存
boolean success = voucherService.update().setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.eq("stock",voucher.getStock()) // 重点是这里,添加了乐观锁
.update();
if (! success){
// 扣除失败
return Result.fail("秒杀券已抢完");
}
// 6. 创建订单
// 6.1 订单id
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7. 返回订单id
return Result.ok();
}
添加了乐观锁导致了成功率过低,异常率高于我们预计的50%,所以需要进行再次修改。
针对本次业务,由于库存天然需要大于0,所以进行再次修改
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = voucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀尚未开始
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已经结束
return Result.fail("秒杀尚未开始");
}
// 4. 判断库存是否充足
if (voucher.getStock()<1) {
// 库存不足
return Result.fail("秒杀券已抢完");
}
// 5. 扣除库存
boolean success = voucherService.update().setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0) // 修改后的乐观锁
.update();
if (! success){
// 扣除失败
return Result.fail("秒杀券已抢完");
}
// 6. 创建订单
// 6.1 订单id
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7. 返回订单id
return Result.ok();
}
现在我们可以发现异常率是50%,可以很好的解决超卖的问题。
3.3.4 一人一单实现(悲观锁实现)
修改订单实现类
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = voucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀尚未开始
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已经结束
return Result.fail("秒杀尚未开始");
}
// 4. 判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("秒杀券已抢完");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// spring事务会失效,所以需要获取代理对象,来完成事务功能
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
@Override
public Result createVoucherOrder(Long voucherId) {
// 查询是否存在该用户的订单
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("您已购买过该券");
}
// 5. 扣除库存
boolean success = voucherService.update().setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 修改后的乐观锁
.update();
if (!success) {
// 扣除失败
return Result.fail("秒杀券已抢完");
}
// 6. 创建订单
// 6.1 订单id
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7. 返回订单id
return Result.ok();
}
导入aspectj
依赖
<!--aspectj的依赖-->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
在启动类中,暴露aspectj
的代理对象
@EnableAspectJAutoProxy(exposeProxy = true) // 暴露aspectj的代理对象
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
3.3.5 集群一人一单
配置集群环境
配置前端代理
集群模式下,会出现错误,每个服务器相当于一个进程,每个进程会有自己的JVM
。而synchronized
是JVM
实现的,所以两个服务器没有共用锁,导致并发问题的发生。
3.4 分布式锁
3.4.1 分布式锁的初级实现版本
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate redisTemplate) {
this.name = name;
this.redisTemplate = redisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
Boolean success = redisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
redisTemplate.delete(KEY_PREFIX + name);
}
}
问题:
上面的这种实现,会出现误删的效果。例如,线程1
业务执行时间较长,超过了设置的超时时间,导致了,业务还没执行完,锁就自动释放了。此时,线程2
尝试获取锁,由于线程1
已经释放了锁,所以线程2
可以获取锁,线程2
此时执行自己的业务,而恰好线程1
业务完成释放锁,造成了线程1
释放了线程2
的锁
修改后的版本,redis
锁
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true);
public SimpleRedisLock(String name, StringRedisTemplate redisTemplate) {
this.name = name;
this.redisTemplate = redisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 1.存入锁的标识
String threadId = ID_PREFIX+Thread.currentThread().getId();
// 2.是否获取成功
Boolean success = redisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId , timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 1.从redis中获取锁标识
String s = redisTemplate.opsForValue().get(KEY_PREFIX + name);
// 2. 判断标识是否相等
String threadId = ID_PREFIX+Thread.currentThread().getId();
if (threadId.equals(s)){
redisTemplate.delete(KEY_PREFIX + name);
}
}
}
3.4.2 lua脚本
由于判断锁和释放锁不是原子性的操作,可能还会导致并发问题的出现。
Java
调用lua
脚本
lua
脚本,位置在resource
目录下
-- 比较线程标识与锁中标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁
return redis.call('del', KEYS[1])
end
return 0
ILock
实现类
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 redisTemplate;
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")); // 获取lua脚本
UNLOCK_SCRIPT.setResultType(Long.class); // 设置lua脚本的返回值类型
}
public SimpleRedisLock(String name, StringRedisTemplate redisTemplate) {
this.name = name;
this.redisTemplate = redisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 1.存入锁的标识
String threadId = ID_PREFIX+Thread.currentThread().getId();
// 2.是否获取成功
Boolean success = redisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId , timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 调用lua脚本
redisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX+Thread.currentThread().getId()
);
}
}
3.4.3 Redisson
- 导入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
redisson
配置文件
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient(){
// 配置类
Config config = new Config();
// 添加redis地址,这里采用单机redis,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.88.130:6379").setPassword("123456");
// 创建客户端
return Redisson.create(config);
}
}
- 使用
@Autowired
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
// 获取可重入锁,指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// 尝试获取锁,获取锁的最大等待时间(期间重试) 锁自动释放时间
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
// 判断是否获取成功
if (isLock) {
try {
System.out.println("执行业务");
} finally {
// 释放锁
lock.unlock();
}
}
}
3.4.4 Redisson原理
获取锁的流程
释放所的流程
通过测试,验我们的流程
private RLock lock ;
@BeforeEach
void setup(){
lock = redissonClient.getLock("order");
}
@Test
void method1() {
// 尝试获取锁
boolean isLock = lock.tryLock();
if (!isLock){
log.error("获取锁失败...1");
}
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");
}
try{
log.info("获取锁成功...2");
log.info("开始执行业务...2");
}finally {
log.warn("准备释放锁...2");
lock.unlock();
}
}
执行到method1
获取锁之后和我们的猜想是一样的通过Hash
进行存储
超时等待原理
1. 异步查询锁的剩余时间
2. 判断设定时间是否大于(查询消耗时间+消耗时间)
- 如果小于 返回 false
- 如果大于 那么会订阅锁的释放消息,在锁释放的通知线程。在此期间线程会进行等待,等待时间为设定时间的剩余时间,
- 如果超过设定时间会返回false
- 如果没超过,会进入循环一直重试,尝试获取锁
超时释放原理
1. redisson内部设置了一个定时任务,每隔设定时间的1/3,进行一次时间的刷新,这样就可以保证任务执行完成。(自己如果设定了超时时间,那么不会有定时任务)
2. 在释放锁的时候,也会把定时任务给释放掉
3.5 优化秒杀应用
现在的流程
3.5.1 基于 Redis阻塞队列 完成秒杀
修改VoucherServiceImpl.java
文件
@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public Result queryVoucherOfShop(Long shopId) {
// 查询优惠券信息
List<Voucher> vouchers = getBaseMapper().queryVoucherOfShop(shopId);
// 返回结果
return Result.ok(vouchers);
}
@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,通过redis来判断库存和一人一单
redisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
}
}
新建lua
脚本
-- seckill.lua
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 2.数据歌ey
-- 2.1.库存ey
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.3.判断用户是否下单SISMEMBER orderKey userId
if (redis.call('sismember', orderKey, userId) == 1) then
--3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4. 扣库存
redis.call('incrby', stockKey, -1)
-- 3.5. 下单保存用户
redis.call("sadd", orderKey, userId)
return 0
修改VoucherOrderServiceImpl.java
文件
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService voucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private StringRedisTemplate redisTemplate;
private final BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024*1024);
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); // 获取lua脚本
SECKILL_SCRIPT.setResultType(Long.class); // 设置lua脚本的返回值类型
}
@PostConstruct // @PostConstruct修饰的方法会在类初始化之后执行
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while (true){
try {
// 1. 获取队列中的订单信息
VoucherOrder voucherOrder = orderTask.take();
// 2. 创建订单
handleVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
log.error("创建订单时出现异常",e);
}
}
}
}
private IVoucherOrderService proxy;
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 1.无法从UserHolder当中获取userID,该函数在子线程当中运行
Long userId =voucherOrder.getUserId();
// 其实我感觉这里不需要再考虑并发的情况了,并发情况已经被Redis处理了
SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:"+userId,redisTemplate);
boolean isLock = simpleRedisLock.tryLock(1200);
if (!isLock) {
log.error("一个用户只允许抢一个优惠券");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
}finally {
simpleRedisLock.unlock();
}
}
@Override
public Result seckillVoucher(Long voucherId) { // 需要对秒杀时间进行判断!
// 获取用户
Long userId = UserHolder.getUser().getId();
Long result = redisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // 没有key类型的参数,不能传null,要传空集合
voucherId.toString(), userId.toString()
);
// 2. 判断结果是否为0
int r = result.intValue();
if (r != 0){
// 2.1 不为0,没有购买资格
return Result.fail(r == 1 ? "库存不足":"不能重复下单");
}
// 2.2 为0,有购买资格,将信息保存到阻塞队列里面
// TODO 保存阻塞队列
long orderId = redisIdWorker.nextId("order:");
// 3. 准备订单信息
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
// 3.1 用户id
voucherOrder.setUserId(userId);
// 3.2 代金券id
voucherOrder.setVoucherId(voucherId);
// 代理对象现如今也无法获取,代理对象也是存储在ThreadLocal当中
proxy = (IVoucherOrderService) AopContext.currentProxy();
orderTask.add(voucherOrder);
// 把代理对象放到成员变量当中
// 4. 返回订单id
return Result.ok(orderId);
}
@Transactional
@Override
public void createVoucherOrder(VoucherOrder voucherOrder) {
// 查询是否存在该用户的订单
Long userId = voucherOrder.getUserId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherOrder).count();
if (count > 0) {
log.error("您已购买过该券");
return ;
}
// 5. 扣除库存
boolean success = voucherService.update().setSql("stock = stock -1")
.eq("voucher_id", voucherOrder)
.gt("stock", 0) // 修改后的乐观锁
.update();
if (!success) {
// 扣除失败
log.error("秒杀券已抢完");
return;
}
// 6. 将订单保存至数据库
save(voucherOrder);
}
}
秒杀优化的思路是什么?
- 先利用Redis完成库存余量、一人一单判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题
- 数据安全问题
3.6 基于消息队列完成秒杀
Redis
实现消息队列的三种方式
list
结构:基于List结构模拟消息队列PubSub
:基本的点对点消息模型Stream
:比较完善的消息队列模型
3.6.1 基于List
消息队列
List
消息队列的优缺点
优点:
- 利用
Redis
存储,不受限于VM内存上限 - 基于
Redis
的持久化机制,数据安全性有保证 - 可以满足消息有序性
缺点:
- 无法避免消息丢失
- 只支持单消费者
3.6.2 基于 PubSubd
消息队列
优点:
- 采用发布订阅模型,支持多生产、多消费
缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失
3.6.3 基于Stream
的消息队列
3.7 达人探店
3.7.1 发布探店笔记
在SystemConstants.java
中修改上传文件路径
public class SystemConstants {
public static final String IMAGE_UPLOAD_DIR = "E:\\redis-code\\nginx-1.18.0\\html\\hmdp\\imgs\\"; // 修改这一行的文件路径,如果有OSS服务器,可以自己重新实现该接口
public static final String USER_NICK_NAME_PREFIX = "user_";
public static final int DEFAULT_PAGE_SIZE = 5;
public static final int MAX_PAGE_SIZE = 10;
}
3.7.2 查看探店笔记
在BlogServiceImpl.java中修改文件
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Autowired
private IUserService userService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryBlogById(Long id) {
Blog blog = getById(id);
if (blog == null){
return Result.fail("笔记不存在");
}
queryBlogUser(blog);
return Result.ok(blog);
}
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}
3.7.3 点赞排行榜功能
修改实体类blog.java
@Data
@Accessors(chain = true)
@TableName("tb_blog")
public class Blog implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 商户id
*/
private Long shopId;
/**
* 用户id
*/
private Long userId;
/**
* 用户图标
*/
@TableField(exist = false)
private String icon;
/**
* 用户姓名
*/
@TableField(exist = false)
private String name;
/**
* 是否点赞过了
*/
@TableField(exist = false)
private Boolean isLike;
/**
* 标题
*/
private String title;
/**
* 探店的照片,最多9张,多张以","隔开
*/
private String images;
/**
* 探店的文字描述
*/
private String content;
/**
* 点赞数量
*/
private Integer liked;
/**
* 评论数量
*/
private Integer comments;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
修改实现类BlogServiceImpl.java
,在实现类中要实现两个功能
- 完成点赞功能,同时添加到数据库和
Redis
当中 - 将
liked
字段进行修改
Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Autowired
private IUserService userService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@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.isBlogLike(blog);
});
return Result.ok(records);
}
@Override
public Result queryBlogById(Long id) {
Blog blog = getById(id);
if (blog == null){
return Result.fail("笔记不存在");
}
queryBlogUser(blog);
isBlogLike(blog);
return Result.ok(blog);
}
private void isBlogLike(Blog blog) {
// 1. 获取用户id
if (UserHolder.getUser() == null){
return;
}
Long userId = UserHolder.getUser().getId();
// 2. 判断用户是否已经点赞了
String key = BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(score != null);
}
@Override
public Result likeBlog(Long id) {
// 1. 获取用户id
Long userId = UserHolder.getUser().getId();
// 2. 判断用户是否已经点赞了
String key = BLOG_LIKED_KEY+ id;
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if (score == null){
// 2.1 如果未点赞,那么数据库的点赞数+1,并将其存入redis集合
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
if (isSuccess){
stringRedisTemplate.opsForZSet().add(key, String.valueOf(userId),System.currentTimeMillis());
}
}else {
// 2.2 如果点赞,那么数据库的点赞数-1,并将其移除redis集合
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
if (isSuccess){
stringRedisTemplate.opsForZSet().remove(key, String.valueOf(userId));
}
}
return Result.ok();
}
@Override
public Result queryBlogLikes(Long id) {
// 1. 查询top5点赞数 zrange key 0 4
String key = BLOG_LIKED_KEY+ id;
// 2. 我只用两个用户查过,发现是有序的,感觉内部实现应该采用的TreeSet
Set<String> userIds = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (userIds == null || userIds.isEmpty()){
return Result.ok(Collections.emptyList());
}
List<User> users = new ArrayList<>(5);
// 分别对每一个id进行查询,我看视频里面是通过自定义排序做的,我觉得太麻烦了,就分别查询了。
userIds.forEach(userId->{
users.add(userService.getById(userId));
});
List<UserDTO> userDTOS = users
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOS);
}
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}
3.8 好友关注
3.8.1 关注和取关
在FollowServiceImpl.java
当中,有两个方法,follo()
用来关注或者取消关注,isFollow()
用来判断是否关注了目标
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private IUserService userService;
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1. 获取登录用户
Long userId = UserHolder.getUser().getId();
// 2. 判断是关注还是取关
if (isFollow){
// 2. 关注
Follow follow = new Follow();
follow.setFollowUserId(followUserId);
follow.setUserId(userId);
boolean isSuccess = save(follow);
if (isSuccess){
String key = "follow:"+userId;
stringRedisTemplate.opsForSet().add(key, String.valueOf(followUserId));
}
}else {
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getFollowUserId, followUserId);
queryWrapper.eq(Follow::getUserId, userId);
boolean isSuccess = remove(queryWrapper);
if (isSuccess){
String key = "follow:"+userId;
stringRedisTemplate.opsForSet().remove(key);
}
}
return Result.ok();
}
@Override
public Result isFollow(Long followUserId) {
// 1. 获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.查询是否关注该用户
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getFollowUserId, followUserId);
queryWrapper.eq(Follow::getUserId, userId);
// 3. 判断是否大于0
int count = count(queryWrapper);
return Result.ok(count>0);
}
}
3.8.2 共同好友
把关注列表存在Redis
的Set
当中,通过 进行交集,就可以查找共同关注。
修改FollowServiceImpl.java
@Override
public Result followCommon(Long id) {
// 1. 获取当前用户
Long userId = UserHolder.getUser().getId();
// 2. 获取当前用户关注列表和目标用户关注列表
Set<String> followIds = stringRedisTemplate.opsForSet().intersect(userId.toString(), id.toString());
if (followIds == null || followIds.isEmpty()){
return Result.ok(Collections.emptyList());
}
List<UserDTO> userDTOS = userService.listByIds(followIds)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOS);
}
3.8.3 feed流
千万粉丝才考虑使用推拉结合,一般采用推模式就可以了。
在保存博客的时候,将id
发送到每个粉丝发件箱,修改BlogServiceImpl.java
的保存博客方法
@Override
public Result saveBlog(Blog blog) {
// 获取用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("保存失败");
}
// 查询该用户所有的粉丝
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getFollowUserId, user.getId());
List<Follow> followList = followService.list(queryWrapper);
for (Follow follow : followList) {
String key = "feed:"+follow.getUserId();
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(),System.currentTimeMillis());
}
return Result.ok();
}