基于【Redis】的黑马点评项目

项目介绍:
基于【Redis】的黑马点评项目_第1张图片

基于【Redis】的黑马点评项目

  • 短信登录
    • 基于Session实现登录
    • 登录和注册功能:
      • 实现登录校验拦截器:
    • 集群的session共享问题:
    • 基于Redis实现短信登录:
    • 登录拦截器的优化:
  • 商户查询缓存
    • 什么是缓存:
    • 添加Redis缓存:
    • 缓存更新策略:
    • 缓存穿透:
    • 缓存雪崩:
    • 缓存击穿
    • 案例:
      • 基于互斥锁解决缓存击穿问题:
    • 封装Redis工具类:
  • 优惠券秒杀
    • 全局唯一ID:
    • 实现优惠券秒杀下单:
    • 超卖问题:
    • 一人一单:
    • 一人一单的并发安全问题:
    • Redis优化秒杀
  • 分布式锁
  • Redisson
    • 基于Redis的分布式锁的优化:
  • 达人探店
    • 发布探店笔记
    • 点赞
    • 点赞排行榜
  • 好友关注
    • 关注和取关
    • 共同关注:
    • 关注推送
    • Feed流的滚动分页:
  • 附近商铺
    • GEO数据结构
    • 附近商铺搜索
  • 用户签到
    • BitMap用法
    • 签到功能
    • 签到统计
  • UV的统计
    • HyperLogLog用法
    • 实现UV统计

短信登录

基于【Redis】的黑马点评项目_第2张图片

基于Session实现登录

基于【Redis】的黑马点评项目_第3张图片
实现发送短信:
此处没有实现真正的短信发布功能,需结合阿里云短信服务。

package com.hmdp.service.impl;

import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.User;
import com.hmdp.mapper.UserMapper;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RegexUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpSession;

/**
 * 

* 服务实现类 *

* */
@Slf4j @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { @Override public Result sendCode(String phone, HttpSession session) { //1. 校验手机号 if(RegexUtils.isPhoneInvalid(phone)) { //2. 如果不符合,返回错误信息 return Result.fail("手机号格式错误!"); } //3. 符合,生成验证码 String code = RandomUtil.randomNumbers(6); //4. 保存验证码到session session.setAttribute("code",code); //5. 发送验证码 (假验证码) log.debug("发送短信验证码成功,验证码:{}",code); //返回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. 校验验证码
    Object cacheCode = session.getAttribute("code");
    String code = loginForm.getCode();
    if(cacheCode == null || !cacheCode.toString().equals(code)){
        //3. 不一致,报错
        return Result.fail("验证码错误!");
    }


    //4. 一致,根据手机号查询用户
    // select * from tb_user where phone = ?
    User user = query().eq("phone", phone).one();

    //5. 判断用户是否存在
    if(user == null){
        //6. 不存在,创建新用户并保存
        user = createUserWithPhone(phone);

    }

    //7. 保存用户信息到session中
    session.setAttribute("user",user);
    return Result.ok();
}

private User createUserWithPhone(String phone) {
    //1. 创建用户
    User user = new User();
    user.setPhone(phone);
    user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    //2.保存用户
    save(user);
    return user;
}

实现登录校验拦截器:

LoginInterceptor

package com.hmdp.utils;

import com.hmdp.dto.UserDTO;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

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);
            return false;
        }

        //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();
    }
}

加入MVC文件:

package com.hmdp.config;

import com.hmdp.utils.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfig implements WebMvcConfigurer {


    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**"
                );
    }
}

集群的session共享问题:

session 共享问题:多台Tomcat并不共享session 存储空间,当请求切换到不同Tomcat服务时导致数据丢失的问题。
基于【Redis】的黑马点评项目_第4张图片
基于Redis实现共享session登录:
基于【Redis】的黑马点评项目_第5张图片
基于【Redis】的黑马点评项目_第6张图片

基于Redis实现短信登录:

Service方法中:
基于【Redis】的黑马点评项目_第7张图片基于【Redis】的黑马点评项目_第8张图片基于【Redis】的黑马点评项目_第9张图片修改拦截器:

package com.hmdp.utils;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class LoginInterceptor implements HandlerInterceptor {

    //这是手动创建的类,不能自动注入   转到MvcConfig
    private StringRedisTemplate stringRedisTemplate;

    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的Token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            // 不存在,拦截
            response.setStatus(401);
            return false;
        }
        // 2.基于Token获取Redis中的用户
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);

        // 3.判断用户是否存在
        if(userMap.isEmpty()){
            //4.不存在,拦截
            response.setStatus(401);
            return false;
        }
        // 5. 将查询到的Hash数据转为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);
        // TODO 7. 刷新Token有效期
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        //8. 放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

基于【Redis】的黑马点评项目_第10张图片

登录拦截器的优化:

基于【Redis】的黑马点评项目_第11张图片刷新的拦截器:
判断是否存在用户

package com.hmdp.utils;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class RefreshTokenInterceptor implements HandlerInterceptor {

    //这是手动创建的类,不能自动注入   转到MvcConfig
    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的Token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2.基于Token获取Redis中的用户
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);

        // 3.判断用户是否存在
        if(userMap.isEmpty()){
            return true;
            /*//4.不存在,拦截
            response.setStatus(401);
            return false;*/
        }
        // 5. 将查询到的Hash数据转为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7. 刷新Token有效期
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        //8. 放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }

}

拦截器:
判断是否需要拦截,设置状态码

package com.hmdp.utils;

import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 判断是否需要拦截(ThreadLocal中是否有用户)
        if(UserHolder.getUser() == null){
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

需修改:MvcConfig
基于【Redis】的黑马点评项目_第12张图片
基于【Redis】的黑马点评项目_第13张图片
此功能是先用session实现了一遍,然后通过redis来实现session,最终基于redis来成功实现短信登录功能。

商户查询缓存

什么是缓存:

基于【Redis】的黑马点评项目_第14张图片

添加Redis缓存:

基于【Redis】的黑马点评项目_第15张图片基于【Redis】的黑马点评项目_第16张图片

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryById(Long id) {
    // 1.从redis查询商品缓存
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(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(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 7.返回
    return Result.ok(shop);
}

缓存更新策略:

基于【Redis】的黑马点评项目_第17张图片基于【Redis】的黑马点评项目_第18张图片基于【Redis】的黑马点评项目_第19张图片基于【Redis】的黑马点评项目_第20张图片ShopServiceImpl:
基于【Redis】的黑马点评项目_第21张图片

@Override
@Transactional
public Result update(Shop shop) {
    Long id = shop.getId();
    if(id == null){
        return Result.fail("店铺 id 不能为空");
    }
    //1. 更新数据库
    updateById(shop);
    //2. 删除缓存
    stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
    return Result.ok();
}

缓存穿透:

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

解决方法:
基于【Redis】的黑马点评项目_第22张图片基于【Redis】的黑马点评项目_第23张图片基于【Redis】的黑马点评项目_第24张图片基于【Redis】的黑马点评项目_第25张图片

缓存雪崩:

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

基于【Redis】的黑马点评项目_第26张图片

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
基于【Redis】的黑马点评项目_第27张图片解决方法:
基于【Redis】的黑马点评项目_第28张图片在这里插入图片描述

案例:

基于互斥锁解决缓存击穿问题:

基于【Redis】的黑马点评项目_第29张图片

package com.hmdp.service.impl;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;

import java.util.concurrent.TimeUnit;

import static com.hmdp.utils.RedisConstants.*;

/**
 * 

* 服务实现类 *

* * @author 虎哥 * @since 2021-12-22 */
@Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public Result queryById(Long id) { // 缓存穿透: // Shop shop = queryWithPassThrough(id); // 用互斥锁解决缓存击穿 Shop shop = queryWithMutex(id); if(shop == null){ return Result.fail("店铺不存在!"); } // 7.返回 return Result.ok(shop); } // 获取 锁 private boolean tryLock(String key){ Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.MINUTES); return BooleanUtil.isTrue(flag); } // 释放 锁 private void unlock(String key){ stringRedisTemplate.delete(key); } // 封装方法 【缓存击穿】 public Shop queryWithMutex(Long id){ // 1.从redis查询商品缓存 String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); // 2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { // 3.存在,直接返回 return JSONUtil.toBean(shopJson, Shop.class); } // 【缓存穿透】判断命中的是否为空值 if(shopJson != null){ // 返回一个错误信息 return null; } // 4. 实现缓存重建 // 4.1 获取互斥锁 String lockKey = LOCK_SHOP_KEY + id; Shop shop = null; try { boolean isLock = tryLock(lockKey); // 4.2 判断是否获取成功 if (!isLock){ // 4.3 失败,则休眠并重试 Thread.sleep(50); return queryWithMutex(id); } // 4.4 成功, // 4.4.1 (获取锁成功应该再次检测redis缓存是否存在,做DoubleCheck。如果存在,则无需重建缓存。) // 4.4.2 根据id查询数据库 shop = getById(id); // 模拟重建的延时 Thread.sleep(200); // 5.不存在,返回错误 if(shop == null){ // 【缓存穿透】将空值写入redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES); //返回错误信息 return null; } // 6.存在,写入redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(); } finally { // 7. 释放互斥锁 unlock(lockKey); } // 8. 返回 return shop; } // 封装方法 【缓存穿透】 public Shop queryWithPassThrough(Long id){ // 1.从redis查询商品缓存 String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); // 2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { // 3.存在,直接返回 return JSONUtil.toBean(shopJson, Shop.class); } // 【缓存穿透】判断命中的是否为空值 if(shopJson != null){ // 返回一个错误信息 return null; } // 4.不存在,根据id查询数据库 Shop shop = getById(id); // 5.不存在,返回错误 if(shop == null){ // 【缓存穿透】将空值写入redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES); //返回错误信息 return null; } // 6.存在,写入redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); // 7.返回 return shop; } @Override @Transactional public Result update(Shop shop) { Long id = shop.getId(); if(id == null){ return Result.fail("店铺 id 不能为空"); } //1. 更新数据库 updateById(shop); //2. 删除缓存 stringRedisTemplate.delete(CACHE_SHOP_KEY + id); return Result.ok(); } }

封装Redis工具类:

封装互斥锁方法和封装缓存击穿和缓存穿透来实现其他功能调用。

package com.hmdp.utils;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
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.TimeUnit;
import java.util.function.Function;

import static com.hmdp.utils.RedisConstants.*;

@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    public CacheClient(StringRedisTemplate stringRedisTemplate){
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private void set(String key, Object value, Long time, TimeUnit unit){
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    private 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));
    }

    // Redis 工具类 【缓存穿透】
    public <T,ID> T queryWithPassThrough(String keyPrefix, ID id, Class<T> type,
                                         Function<ID, T> 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);
        }

        // 【缓存穿透】判断命中的是否为空值
        if(json != null){
            // 返回一个错误信息
            return null;
        }

        // 4.不存在,根据id查询数据库
        T t = dbFallback.apply(id);
        // 5.不存在,返回错误
        if(t == null){
            //  【缓存穿透】将空值写入redis
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            //返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, t, time, unit);
        // 7.返回
        return t;
    }

    // Redis 工具类 【缓存击穿】
    public <T,ID> T queryWithMutex(String keyPrefix,ID id, Class<T> type,
                                   Function<ID, T> dbFallback, Long time, TimeUnit unit){
        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. 实现缓存重建
        // 4.1 获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        T t = 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 成功,
            // 4.4.1 (获取锁成功应该再次检测redis缓存是否存在,做DoubleCheck。如果存在,则无需重建缓存。)
            // 4.4.2 根据id查询数据库
            t = dbFallback.apply(id);
            // 模拟重建的延时
            Thread.sleep(200);
            // 5.不存在,返回错误
            if(t == null){
                //  【缓存穿透】将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                //返回错误信息
                return null;
            }
            // 6.存在,写入redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(t), time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException();
        } finally {
            // 7. 释放互斥锁
            unlock(lockKey);
        }

        // 8. 返回
        return t;
    }


    // 获取 锁
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.MINUTES);
        return BooleanUtil.isTrue(flag);
    }
    // 释放 锁
    private void unlock(String key){
        stringRedisTemplate.delete(key);
    }




}

使用实现:
基于【Redis】的黑马点评项目_第30张图片为解决缓存问题,数据量较小时可以使用互斥锁来解决。

优惠券秒杀

全局唯一ID:

基于【Redis】的黑马点评项目_第31张图片Redis 全局id生成器:
自己写了一个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;

/**
 * Redis ID 生成器
 */
@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;
    }



}

测试:

@Resource
private RedisIdWorker redisIdWorker;

private ExecutorService es = Executors.newFixedThreadPool(500);

@Test
void testIdWorker() throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(300);

    Runnable task = () -> {
      for (int i = 0; i < 100; i++){
          long id = redisIdWorker.nextId("order");
          System.out.println("id = "+ id);
      }
      latch.countDown();
    };
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 300; i++) {
        es.submit(task);
    }
    latch.await();
    long end = System.currentTimeMillis();
    System.out.println("time = " + (end - begin));
}

实现优惠券秒杀下单:

基于【Redis】的黑马点评项目_第32张图片
添加优惠券:
基于【Redis】的黑马点评项目_第33张图片基于【Redis】的黑马点评项目_第34张图片

@Resource
private ISeckillVoucherService seckillVoucherService;

@Resource
private RedisIdWorker redisIdWorker;

@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.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 = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId).update();
    if(!success){
        //扣减失败
        return Result.fail("库存不足!");
    }
    //6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 订单id
    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(orderId);
}

超卖问题:

基于【Redis】的黑马点评项目_第35张图片基于【Redis】的黑马点评项目_第36张图片CAS法:
基于【Redis】的黑马点评项目_第37张图片基于【Redis】的黑马点评项目_第38张图片
基于【Redis】的黑马点评项目_第39张图片乐观锁不是锁,是一个解决线程安全问题的方法,如上,改变sql语句来实现乐观锁。

一人一单:

基于【Redis】的黑马点评项目_第40张图片依赖:
基于【Redis】的黑马点评项目_第41张图片基于【Redis】的黑马点评项目_第42张图片

一人一单的并发安全问题:

基于【Redis】的黑马点评项目_第43张图片基于【Redis】的黑马点评项目_第44张图片

@Resource
private ISeckillVoucherService seckillVoucherService;

@Resource
private RedisIdWorker redisIdWorker;

@Override
public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.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()) {
        // 获取代理对象(事务)
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    }
}

@Transactional
public Result createVoucherOrder(Long voucherId) {
    //5 一人一单
    Long userId = UserHolder.getUser().getId();

    //5.1 查询订单
    Long count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    //5.2 判断是否存在
    if (count > 0) {
        //已买
        return Result.fail("用户已经购买过一次!");
    }

    //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) {
        //扣减失败
        return Result.fail("库存不足!");
    }

    //7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //7.1 订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    //7.2 用户id
    voucherOrder.setUserId(userId);
    //7.3 代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    //8.返回订单id
    return Result.ok(orderId);
}

Redis优化秒杀

基于【Redis】的黑马点评项目_第45张图片基于【Redis】的黑马点评项目_第46张图片基于【Redis】的黑马点评项目_第47张图片
基于【Redis】的黑马点评项目_第48张图片

分布式锁

基本原理:
基于【Redis】的黑马点评项目_第49张图片基于【Redis】的黑马点评项目_第50张图片基于【Redis】的黑马点评项目_第51张图片基于Redis的分布式锁:

基于【Redis】的黑马点评项目_第52张图片基于Redis实现分布式锁初级版本:
基于【Redis】的黑马点评项目_第53张图片基于【Redis】的黑马点评项目_第54张图片原子性:
基于【Redis】的黑马点评项目_第55张图片SimpleRedisLock:

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);
        }
    }*/
}

基于【Redis】的黑马点评项目_第56张图片

Redisson

依赖:


<dependency>
    <groupId>org.redissongroupId>
    <artifactId>redissonartifactId>
    <version>3.13.6version>
dependency>

RedissonConfig:

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();
        config.useSingleServer().setAddress("redis://114.115.208.175");
        // 创建RedissonClient 对象
        return Redisson.create(config);
    }

}

基于Redis的分布式锁的优化:

基于【Redis】的黑马点评项目_第57张图片

达人探店

发布探店笔记

在下面完整展现

点赞

基于【Redis】的黑马点评项目_第58张图片在下面完整展现

点赞排行榜

基于【Redis】的黑马点评项目_第59张图片代码完整实现:

@Resource
private IUserService userService;
@Resource
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.isBlogLiked(blog);
    });
    return Result.ok(records);
}

/**
 * 点赞功能
 * @param id
 * @return
 */
@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 数据库点赞数
        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();
}

/**
 * 显示blog
 * @param id
 * @return
 */
@Override
public Result queryBlogById(Long id) {
    // 1. 查询blog
    Blog blog = getById(id);
    if(blog == null){
        return Result.fail("笔记不存在!");
    }
    // 2. 查询blog有关的用户
    queryBlogUser(blog);
    // 3. 查询bolg是否被点赞
    isBlogLiked(blog);
    return Result.ok(blog);
}

/**
 * 展示点赞的前5位人 点赞排行榜
 * @param id
 * @return
 */
@Override
public Result queryBlogLikes(Long id) {
    String key = BLOG_LIKED_KEY + id;
    // 1.查询top5 的点赞用户 zrange key 0 4
    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查询用户 / 转成UserDTO / 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()
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    // 4.返回
    return Result.ok(userDTOS);
}


private void isBlogLiked(Blog blog) {
    UserDTO user = UserHolder.getUser();
    if(user == null){
        // 用户未登录,无需查询是否点赞
        return;
    }
    // 1. 获取登录用户
    Long userId = user.getId();
    // 2.判断当前用户是否已经点赞
    String key = BLOG_LIKED_KEY + blog.getId();
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    blog.setIsLike(score != null);
}

private void queryBlogUser(Blog blog) {
    Long userId = blog.getUserId();
    User user = userService.getById(userId);
    blog.setName(user.getNickName());
    blog.setIcon(user.getIcon());
}

好友关注

关注和取关

基于【Redis】的黑马点评项目_第60张图片

@Resource
    private StringRedisTemplate stringRedisTemplate;
    
    /**
     * 判断是否关注还是取关
     */
    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        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 followUserId
                stringRedisTemplate.opsForSet().add(key, followUserId.toString());
            }
        }else {
            // 3.取关,删除
            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 isFollow(Long followUserId) {
        Long userId = UserHolder.getUser().getId();
        // 1.查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ?
        Integer count = Math.toIntExact(query().eq("user_id", userId).eq("follow_user_id", followUserId).count());
        return Result.ok(count > 0);
    }
}

共同关注:

基于【Redis】的黑马点评项目_第61张图片Redis 中set方法,SINTER 可以取到交集。
基于【Redis】的黑马点评项目_第62张图片

@Override
public Result followCommons(Long id) {
    // 1.获取当前用户
    Long userId = UserHolder.getUser().getId();
    String key1 = "follows:" + userId;
    // 2.求交集
    String key2 = "follows:" + id;
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, 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】的黑马点评项目_第63张图片Feed流
基于【Redis】的黑马点评项目_第64张图片拉模式:

基于【Redis】的黑马点评项目_第65张图片推模式:
基于【Redis】的黑马点评项目_第66张图片推拉结合模式:

基于【Redis】的黑马点评项目_第67张图片优点:

基于【Redis】的黑马点评项目_第68张图片此项目中用户量少,使用推模式:

基于【Redis】的黑马点评项目_第69张图片

/**
 * 保存笔记,并推送给粉丝
 * @param blog
 * @return
 */
@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());
    }
    // 3.返回id
    return Result.ok(blog.getId());
}

Feed流的滚动分页:

基于【Redis】的黑马点评项目_第70张图片基于【Redis】的黑马点评项目_第71张图片
基于【Redis】的黑马点评项目_第72张图片

/**
 * feed流 滚动分页
 * @param max
 * @param offset
 * @return
 */
@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;
    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;
    int os = 1;
    for (ZSetOperations.TypedTuple<String> tuple: typedTuples) {
        // 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.查询bolg是否被点赞
        isBlogLiked(blog);
    }


    // 6.封装并返回
    ScrollResult r = new ScrollResult();
    r.setList(blogs);
    r.setOffset(os);
    r.setMinTime(minTime);

    return Result.ok(r);
}

附近商铺

GEO数据结构

基于【Redis】的黑马点评项目_第73张图片

附近商铺搜索

基于【Redis】的黑马点评项目_第74张图片

/**
 * 附近商铺
 * @param typeId
 * @param current
 * @param x
 * @param y
 * @return
 */
@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
    // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
    String key = SHOP_GEO_KEY + typeId;
    GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
            .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);
}

用户签到

BitMap用法

基于【Redis】的黑马点评项目_第75张图片

签到功能

/**
 * 签到功能
 */
@Override
public Result sign() {
    // 1.获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.获取日期
    LocalDateTime now = LocalDateTime.now();
    // 3.拼接key
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyy-MM"));
    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();
}

签到统计

基于【Redis】的黑马点评项目_第76张图片基于【Redis】的黑马点评项目_第77张图片

/**
 * 连续签到统计功能
 * @return
 */
@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.获取本月截至今天为止的所有的签到记录,返回的是一个十进制的数字
    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位// 6.2 判断这个bit位是否为0
        if((num & 1) == 0){
            // 6.3 如果为0,说明未签到,结束
            break;
        }else{
            // 6.4 如果不为0,说明已签到,计数器+1
            count++;
        }
        // 6.5 把数字右移一位,抛弃最后一个bit位,继续下一个bit位。
        num >>>=1;
    }
    return Result.ok(count);
}

UV的统计

HyperLogLog用法

基于【Redis】的黑马点评项目_第78张图片

实现UV统计

基于【Redis】的黑马点评项目_第79张图片
此篇文章仅供大家学习参考,有问题可以私信我哦…

你可能感兴趣的:(实战项目,redis,java,servlet,spring,boot,后端)