点评demo

0.前言

功能概要

image.png

数据库设计

1. tb_blog (博客/探店笔记表)

功能: 存储用户发布的探店笔记内容

字段 类型 说明
id bigint(20) 主键,自增
shop_id bigint(20) 关联的商户ID
user_id bigint(20) 发布用户ID
title varchar(255) 笔记标题
images varchar(2048) 图片路径,多张用逗号分隔,最多9张
content varchar(2048) 文字描述内容
liked int(8) 点赞数量
comments int(8) 评论数量
create_time timestamp 创建时间
update_time timestamp 更新时间

示例数据: 包含美食探店笔记,如"无尽浪漫的夜晚"、“人均30💰杭州港式茶餐厅"等

2. tb_blog_comments (博客评论表)

功能: 存储博客的评论信息,支持多级评论

字段 类型 说明
id bigint(20) 主键,自增
user_id bigint(20) 评论用户ID
blog_id bigint(20) 关联的博客ID
parent_id bigint(20) 父评论ID,一级评论为0
answer_id bigint(20) 回复的评论ID
content varchar(255) 评论内容
liked int(8) 点赞数
status tinyint(1) 状态: 0正常, 1被举报, 2禁止查看
create_time timestamp 创建时间
update_time timestamp 更新时间

设计亮点: 通过parent_id和answer_id支持多层级的评论回复

3. tb_follow (关注关系表)

功能: 记录用户之间的关注关系

字段 类型 说明
id bigint(20) 主键,自增
user_id bigint(20) 用户ID
follow_user_id bigint(20) 被关注的用户ID
create_time timestamp 创建时间

用途: 实现社交功能,构建用户关系网络

4. tb_shop (商铺表)

功能: 存储商铺的基本信息

字段 类型 说明
id bigint(20) 主键,自增
name varchar(128) 商铺名称
type_id bigint(20) 商铺类型ID
images varchar(1024) 商铺图片,多张用逗号分隔
area varchar(128) 商圈,如"陆家嘴”
address varchar(255) 详细地址
x double 经度
y double 纬度
avg_price bigint(10) 人均价格(整数)
sold int(10) 销量
comments int(10) 评论数量
score int(2) 评分(1-5分,乘10保存)
open_hours varchar(32) 营业时间
create_time timestamp 创建时间
update_time timestamp 更新时间

示例商铺: 包含103茶餐厅、海底捞火锅、开乐迪KTV等14家商铺

5. tb_shop_type (商铺类型表)

功能: 商铺分类管理

字段 类型 说明
id bigint(20) 主键,自增
name varchar(32) 类型名称
icon varchar(255) 图标路径
sort int(3) 排序
create_time timestamp 创建时间
update_time timestamp 更新时间

分类: 美食、KTV、丽人美发、健身运动、按摩足疗、美容SPA、亲子游乐、酒吧、轰趴馆、美睫美甲

6. tb_user (用户表)

功能: 存储用户基本信息

字段 类型 说明
id bigint(20) 主键,自增
phone varchar(11) 手机号(唯一索引)
password varchar(128) 加密密码
nick_name varchar(32) 昵称
icon varchar(255) 头像路径
create_time timestamp 创建时间
update_time timestamp 更新时间

安全设计: 密码加密存储,手机号唯一

7. tb_user_info (用户详细信息表)

功能: 存储用户的扩展信息

字段 类型 说明
user_id bigint(20) 主键,关联用户ID
city varchar(64) 城市
introduce varchar(128) 个人介绍
fans int(8) 粉丝数
followee int(8) 关注数
gender tinyint(1) 性别: 0男, 1女
birthday date 生日
credits int(8) 积分
level tinyint(1) 会员等级(0-9)
create_time timestamp 创建时间
update_time timestamp 更新时间

8. tb_voucher (代金券表)

功能: 管理商铺的优惠券信息

字段 类型 说明
id bigint(20) 主键,自增
shop_id bigint(20) 商铺ID
title varchar(255) 券标题
sub_title varchar(255) 副标题
rules varchar(1024) 使用规则
pay_value bigint(10) 支付金额(分)
actual_value bigint(10) 抵扣金额(分)
type tinyint(1) 类型: 0普通券, 1秒杀券
status tinyint(1) 状态: 1上架, 2下架, 3过期
create_time timestamp 创建时间
update_time timestamp 更新时间

9. tb_seckill_voucher (秒杀券表)

功能: 存储秒杀券的特殊信息

字段 类型 说明
voucher_id bigint(20) 主键,关联券ID
stock int(8) 库存数量
begin_time timestamp 生效时间
end_time timestamp 失效时间
create_time timestamp 创建时间
update_time timestamp 更新时间

特点: 与tb_voucher是一对一关系,专门处理秒杀场景

10. tb_voucher_order (优惠券订单表)

功能: 记录用户购买优惠券的订单

字段 类型 说明
id bigint(20) 主键(非自增)
user_id bigint(20) 用户ID
voucher_id bigint(20) 优惠券ID
pay_type tinyint(1) 支付方式: 1余额, 2支付宝, 3微信
status tinyint(1) 订单状态: 1未支付, 2已支付, 3已核销, 4已取消, 5退款中, 6已退款
create_time timestamp 下单时间
pay_time timestamp 支付时间
use_time timestamp 核销时间
refund_time timestamp 退款时间
update_time timestamp 更新时间

设计亮点: 完整的订单状态流转,支持退款流程

11. tb_sign (签到表)

功能: 记录用户的签到信息

字段 类型 说明
id bigint(20) 主键,自增
user_id bigint(20) 用户ID
year year 签到年份
month tinyint(2) 签到月份
date date 签到日期
is_backup tinyint(1) 是否补签

用途: 用户激励体系,支持补签功能

1.短信登录

基于session实现登录

1.登录流程

ping-mu-jie-tu-2025-12-16-183317.png

2.代码实现

发送短信验证码

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Override
    public Result sendcode(String phone, HttpSession session){
        //1.校验手机号,utils的工具包
        if(RegexUtils.isPhoneInvalid(phone)){
            //2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }

        //3.符合,生成验证码,一个java的工具包
        String code = RandomUtil.randomNumbers(6);

        //4.保存验证码道session
        session.setAttribute("code",code);

        //5.发送验证码,需要调用第三方的短信平台,比如说阿里云
        //TODO
        log.debug("发送短信验证码成功,验证码:{}",code);

        //返回ok
        return Result.ok(code);
    }
}

短信验证码验证和登录

@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.不存在,创建用户并保存,只需要填充phone和nickname字段即可
        user = createUserWithPhone(String 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;
}

校验登录状态

/*
	在utils中添加LoginInterceptor.java作为拦截器
*/
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中的user
        Object user = session.getAttribute("user");
        //3.判断用户是否在存在
        if(user==null){
            //4.不存在,返回401状态码
            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();
    }
}


/*
	在config中添加MvcConfig.java
*/
@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/**"
                );
    }
}

/*
	controller拿到用户登录的信息,通过这个ThreadLocal	
*/
@GetMapping("/me")
public Result me(){
    // 获取当前登录的用户并返回
    UserDTO user = UserHolder.getUser();
    return Result.ok(user);
}

隐藏用户信息

/*
	1.需要在登录的时候,将传入session的user更改成userDTO
	2.更改UserHolder中的所有的相关信息和拦截器调用信息为userDTO
	3.更改me接口,return一个UserDTO信息
*/
//登录时保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

集群的session共享问题

image.png

基于Redis实现共享session问题

1.Redis字段选择

设计key的要求

  • 唯一性
  • 便携带

image.png

2.Redis实现验证码发送登录问题

image.png

3.Redis实现校验登录功能

image.png

4.代码实现

发送短信验证码

 @Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result sendcode(String phone, HttpSession session){
    //1.校验手机号,utils的工具包
    if(RegexUtils.isPhoneInvalid(phone)){
        //2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }

    //3.符合,生成验证码,一个java的工具包
    String code = RandomUtil.randomNumbers(6);

    //4.保存验证码到redis
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);

    //5.发送验证码,需要调用第三方的短信平台,比如说阿里云
    log.debug("发送短信验证码成功,验证码:{}",code);

    //返回ok
    return Result.ok(code);
}

短信验证码验证和登录

@Override
public Result login(LoginFormDTO loginForm, HttpSession session){
    //1.校验手机号
    String phone = loginForm.getPhone();
    if(RegexUtils.isPhoneInvalid(phone)){
        return Result.fail("手机号格式错误!");
    }

    //2.从redis中获取验证码并进行校验
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
    String code = loginForm.getCode();
    if(cacheCode==null || !cacheCode.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.不存在,创建用户并保存,只需要填充phone和nickname字段即可
        user = createUserWithPhone(phone);
    }

    //7.保存用户信息到redis
    //7.1随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString();
    //7.2将user对象转为hash存储
    UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
                //解决id字段是整型而不是字符串类型
                CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString())
        );
    //7.3存储
    String tokenKey = LOGIN_USER_KEY+token;
    stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);   //添加hash对象
    stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);

    //8.返回token
    return Result.ok(token);

校验登录状态

  • 需要在MvcConfig中导入RedisTemplate
  • 使用拦截器是为了解决redis像session一样只要有操作就会自动延长过期时间
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中的用户
    String key = RedisConstants.LOGIN_USER_KEY + token;
    Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);

    //3.判断用户是否在存在
    if(userMap.isEmpty()){
        //4.不存在,返回401状态码
        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(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

    //8.放行
    return true;
}


/*
	此时的MVCCONFIG需要更新
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

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

登录拦截器的优化

  • 如果用户访问的全都不是在拦截器的页面,token仍然会失效
//具体做法:拷贝一份新的RefreshTokenInterceptor,并重写一下LoginInterceptor

@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
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;

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

2.商户查询缓存

什么是缓存

缓存的级别

image.png

缓存的优缺点

image.png

添加Redis缓存

1.添加商户查询缓存

image-20251218111733464

代码实现

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY +id;
        //1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //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(key,JSONUtil.toJsonStr(shop));

        //7.返回数据
        return Result.ok(shop);
    }
}

2.添加商铺类型查询业务

image.png

代码实现

@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryTypeList() {
        String typeKey= RedisConstants.CACHE_TYPE_KEY;
        //1.从redis中查询
        Long typeListSize = stringRedisTemplate.opsForList().size(typeKey);
        //2.redis存在数据
        if (typeListSize!=null&&typeListSize!=0){
            List<String> typeJsonList = stringRedisTemplate.opsForList().range(typeKey, 0, typeListSize-1);
            List<ShopType> typeList=new ArrayList<>();
            for (String typeJson : typeJsonList) {
                typeList.add(JSONUtil.toBean(typeJson,ShopType.class));
            }
            return Result.ok(typeList);
        }
        //3.redis不存在数据 查询数据库
        List<ShopType> typeList = query().orderByAsc("sort").list();
        //4.数据库中不存在相关数据
        if (typeList==null){
            //数据库不存在数据
            return Result.fail("发生错误");
        }
        //转换
        List<String> typeJsonList=new ArrayList<>();
        for (ShopType shopType : typeList) {
            typeJsonList.add(JSONUtil.toJsonStr(shopType));
        }
        //5.数据库存在数据 写入redis
        stringRedisTemplate.opsForList().rightPushAll(typeKey,typeJsonList);
        //6.返回数据
        return Result.ok(typeList);
    }
}

缓存更新策略

当数据库的数据发生修改时,需要及时更新redis中的值

1.三种更新策略

image.png

2.主动更新策略

image.png

image.png

image.png

image.png

3.案例:实现商铺缓存和数据库的双写一致

需求

image.png

代码实现

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

缓存穿透

问题详情

image.png

缓存空对象*

image.png

public Shop queryWithPassThrough(Long id){
    String key = RedisConstants.CACHE_SHOP_KEY +id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if(StrUtil.isNotBlank(shopJson)){
        //3.存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson,Shop.class);
        return Result.ok(shop);
    }
    //判断是否为空值,如果是“”,就不用返回数据库的查询了,如果是null,就需要进行数据库的查询(因为数据库没有查到会默认传一个“”,省略了查数据库的这一步)
    if(shopJson != null){
        return Result.fail("店铺信息不存在!");
    }

    //4.不存在,根据id查询数据库
    Shop shop = getById(id);
    //5.数据库不存在,返回错误
    if(shop==null){
        //将空值写入redis
        stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
        return Result.fail("店铺不存在");
    }
    //6.存在,将查询结果写入redis
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

    //7.返回数据
    return Result.ok(shop);
}

缓存雪崩

缓存击穿

image.png

image.png

image.png

1.互斥锁

案例:

image.png

解决办法:setnx

image.png

代码实现

@Override
public Result queryById(Long id) {
    //缓存穿透
    Shop shop = queryWithPassThrough(id);
    if(shop == null){
        return Result.fail("店铺查询失败");
    }
    //返回数据
    return Result.ok(shop);
}

//封装缓存穿透的代码——基于锁机制
public Shop queryWithPassThrough(Long id) {
    String key = RedisConstants.CACHE_SHOP_KEY +id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在,isNotBlank方法会剔除掉null,""的情况
    if(StrUtil.isNotBlank(shopJson)){
        //3.存在,直接返回
        return JSONUtil.toBean(shopJson,Shop.class);
    }
    //4.实现缓存重建
    //4.1 获取互斥锁
    String lockKey = RedisConstants.LOCK_SHOP_KEY+id;
    Shop shop = null;
    try {
        boolean isLock = tryLock(lockKey);
        //4.2 判断是否获取成功
        if(!isLock){
            //4.3 获取失败,休眠
            Thread.sleep(50);
            return queryWithPassThrough(id);  //递归处理
        }
        //4.4 成功,进行数据库的查询和缓存重建
        shop = getById(id);
        //模拟重建的超时
        Thread.sleep(200);

        //5.数据库不存在,返回错误
        if(shop==null){
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }
        //6.存在,将查询结果写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }finally {
        //7.释放互斥锁
        unlock(lockKey);
    }

    //8.返回数据
    return shop;
}

//获取锁
private boolean tryLock(String key){
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
    //如果flag的值为null,自动进行拆箱将包装类转换为基本数据类型时会抛出NullPointerException
    return BooleanUtil.isTrue(flag);
}
//删除锁
private boolean unlock(String key){
    return stringRedisTemplate.delete(key);
}

2.逻辑过期

案例

image.png

代码实现

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

//封装缓存穿透的代码——基于逻辑过期
public Shop queryWithLogicalExpire(Long id) {
    String key = RedisConstants.CACHE_SHOP_KEY +id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if(StrUtil.isBlank(shopJson)){
        //3.不存在,直接返回null
        return null;
    }
    //4.命中,需要把这个json对象反序列化
    RedisData redisDate = JSONUtil.toBean(shopJson, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisDate.getData();, Shop.class);
    LocalDateTime expireTime = redisDate.getExpireTime();

    //5,判断是否过期
    if (expireTime.isAfter(LocalDateTime.now())) {
        //5.1 未过期,直接返回店铺信息
        return shop;
    }
    //5.2 已过期,需要缓存重建
    //6.缓存重建
    //6.1 获取互斥锁
    String localKey = RedisConstants.LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(localKey);
    //6.2 判断是否获取锁成功
    if(isLock){
        //6.3 成功,开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                //重建缓存
                this.saveShopToRedis(id,20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                //释放锁
                unlock(localKey);
            }
        });
    }
    //6.4 返回过期的商铺信息
    return shop;
}

public void saveShopToRedis(Long id,Long expireSeconds){
    //1.查询店铺数据
    Shop shop = getById(id);
    Thread.sleep(200);
    //2.封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    //3.写入Redis
    stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}

缓存封装工具

1.方法类型

image.png

2.代码封装

@Slf4j
@Component
public class CacheClient {
    private final StringRedisTemplate stringRedisTemplate;

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

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

    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
        RedisData redisData = new RedisData();
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        redisData.setData(value);
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    public <R,ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix +id;
        //1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if(StrUtil.isNotBlank(json)){
            //3.存在,直接返回
            return JSONUtil.toBean(json,type);
        }
        //判断是否为空值,如果是“”,就不用返回数据库的查询了,如果是null,就需要进行数据库的查询(因为数据库没有查到会默认传一个“”,省略了查数据库的这一步)
        if(json != null){
            return null;
        }

        //4.不存在,根据id查询数据库
        R r =  dbFallback.apply(id);
        //5.数据库不存在,返回错误
        if(r==null){
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }
        //6.存在,将查询结果写入redis
        this.stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(r),time,unit);

        //7.返回数据
        return r;
    }

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    //封装缓存穿透的代码——基于逻辑过期
    public <R,ID> R queryWithLogicalExpire(
            String prefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit) {
        String key = prefix +id;
        //1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if(StrUtil.isBlank(json)){
            //3.不存在,直接返回null
            return null;
        }
        //4.命中,需要把这个json对象反序列化
        RedisData redisDate = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisDate.getData(), type);
        LocalDateTime expireTime = redisDate.getExpireTime();

        //5,判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //5.1 未过期,直接返回店铺信息
            return r;
        }
        //5.2 已过期,需要缓存重建
        //6.缓存重建
        //6.1 获取互斥锁
        String localKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(localKey);
        //6.2 判断是否获取锁成功
        if(isLock){
            //6.3 成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    //查询数据库
                    R r1 = dbFallback.apply(id);
                    //写入redis
                    this.setWithLogicalExpire(key,r1,time,unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unlock(localKey);
                }
            });
        }
        //6.4 返回过期的商铺信息
        return r;
    }

    //获取锁
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
        //如果flag的值为null,自动进行拆箱将包装类转换为基本数据类型时会抛出NullPointerException
        return BooleanUtil.isTrue(flag);
    }
    //删除锁
    private boolean unlock(String key){
        return stringRedisTemplate.delete(key);
    }
}

3.实现样例

//缓存穿透
        //Shop shop = cacheClient.
        //queryWithPassThrough(RedisConstants.CACHE_SHOP_KEY,id,Shop.class,this::getById, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);

        //逻辑过期解决缓存击穿
        Shop shop = cacheClient.
                queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY,id,Shop.class,this::getById,20L,TimeUnit.SECONDS);

3.优惠券秒杀

全局唯一ID

1.问题来源

image.png

2.全局id生成器

image.png

image.png

image.png

3.代码实现

private static final long BEGIN_TIMESTAMP = 1735689600;
//序列号位数
private static final int COUNT_BITS = 32;

@Resource
private StringRedisTemplate stringRedisTemplate;

public long nextId(String keyPrefix){

    //1.生成时间戳,当前时间-初始时间
    LocalDateTime now = LocalDateTime.now();
    long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
    long timeStamp = nowSecond-BEGIN_TIMESTAMP;

    //2.生成序列号,为了防止序列号进行超标,所以引入了时间key
    String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
    Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

    //3.借助位运算拼接并返回
    return timeStamp << COUNT_BITS | count;
}

4.并发测试

@Test
void testIdWorker() throws InterruptedException {

    // CountDownLatch:并发控制工具,这里初始计数为 300(对应 300 个并发任务)
    CountDownLatch latch = new CountDownLatch(300);

    // 定义一个并发任务 Runnable
    Runnable task = () -> {
        // 每个线程循环生成 100 个 ID
        for(int i=0;i<100;i++){
            // 调用 RedisIdWorker 生成分布式唯一 ID,"order" 是业务前缀
            long id = redisIdWorker.nextId("order");
            // 打印生成的 ID,方便观察是否有重复
            System.out.println("id = "+id);
        }
        //把 CountDownLatch 的计数器减 1
        latch.countDown();
    };

    // 记录开始时间,用于统计生成 ID 的耗时
    long begin = System.currentTimeMillis();

    // 向线程池提交 300 个并发任务
    for(int i=0;i<300;i++){
        executorService.execute(task);
    }
    // 主线程阻塞,等待所有任务执行完成
    latch.await();
    // 记录结束时间
    long end = System.currentTimeMillis();

    // 输出从提交任务到当前时刻的耗时
    System.out.println("time:"+(end-begin));
}

实现优惠券秒杀下单

1.需求分析

image.png

2.添加优惠券(秒杀)

代码实现:

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

post测试样例:http://localhost:8081/voucher/seckill

{
  "shopId": 1,
  "title": "100元代金券",
  "subTitle": "新用户专享",
  "rules": "仅限堂食使用,不可叠加其他优惠",
  "payValue": 7000,
  "actualValue": 10000,
  "type": 1,
  "stock": 100,
  "beginTime": "2025-01-01T10:00:00",
  "endTime": "2030-01-07T23:59:59"
}

3.秒杀下单

功能需求

image.png

image.png

代码实现

@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
    voucherOrder.setUserId(UserHolder.getUser().getId());
    //6.3设置代金券id
    voucherOrder.setVoucherId(voucherId);
    //6.4保存到数据库
    save(voucherOrder);

    //7.返回订单
    return Result.ok(orderId);
}

超卖问题

1.问题来源

image.png

2.解决方案

image.png

image.png

3.乐观锁

版本号法

image.png

CAS法

image.png

4.代码实现

//5.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId).gt("stock",0).update();

一人一单

1.解决方案

image.png

2.代码实现

pom.xml中添加依赖

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

添加启动类

@EnableAspectJAutoProxy(exposeProxy = true)

悲观锁解决一人一单

@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 > 0L){
        //用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

    //6.扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId).gt("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(UserHolder.getUser().getId());
    //7.3设置代金券id
    voucherOrder.setVoucherId(voucherId);
    //7.4保存到数据库
    save(voucherOrder);

    //8.返回订单
    return Result.ok(orderId);
}

分布式锁

1.集群模式下的并发安全问题(TODO)

添加集群

image.png

配置反向代理

image.png

重启nginx nginx.exe -s reload

问题来源

image.png

2.分布式锁原理

image.png

image.png

image-20251223143400696

3.redis实现分布式锁

image.png

image.png

代码实现

public class SimpleRedisLock implements ILock{

    private final String name;
    private final StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name,StringRedisTemplate stringRedisTemplate){
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";

    @Override
    public boolean tryLock(Long timeoutSec) {
        //获取线程标识
        long threadId = Thread.currentThread().getId();
        //获取锁
        Boolean success = stringRedisTemplate.opsForValue().
                setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        //防止拆箱为NULL
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        //释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

使用样例

//创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean isLock = lock.tryLock(1200L);
//判断锁是否获取成功
if(!isLock){
    //获取锁失败或者重试
    return Result.fail("一个人不允许重复下单");
}

try {
    //获取代理对象,避免没提交到数据库就释放锁的安全问题
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
    throw new RuntimeException(e);
} finally {
    lock.unlock();
}

4.误删问题

问题来源:业务未完成超时释放锁 。导致线程2,3全都获取锁执行成功

image.png

**解决办法:**判断获取锁的标识前后是否一致。

image.png

代码实现

private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString() + '-';

@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);
    //防止拆箱为NULL
    return Boolean.TRUE.equals(success);
}

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

5.原子性问题

问题来源判断锁标识释放锁之间产生了阻塞问题,导致锁超时自动释放,导致线程2,3并发安全问题、导致于线程1,3的执行都成功

image.png

Lua语言

image.png

Lua语言的执行

image.png

Lua脚本:实现判断锁标识和释放锁的一致性

-- 比较线程标识与锁中的线程标识是否一致

if(redis.call('get',KEYS[i]) == ARGV[i]) then
    -- 释放锁 del key
    return redis.call('del',KEYS[i])
end
return 0

java调用这个lua脚本

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 void unlock() {
    //调用lua脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX+name),
            ID_PREFIX+Thread.currentThread().getId()
    );
}

6.Redisson重构

问题来源

快速入门

<!--引入依赖-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>
/**
 * 添加Redisson配置
 * @author Minjie
 */
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        //配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        //创建RedissonClient对象
        return Redisson.create(config);
    }
}

使用样例

//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("order:" + userId);
//获取锁
boolean isLock = lock.tryLock();
(1) 可重入锁

问题来源Setnx 中的NX 是互斥的参数

解决办法

Lua脚本实现

-- 这里不去实现了,redisson对这个功能做了一个集成
(2) 锁重试

lock.trylock(long waitTime,long leaseTime,TimeUnit) //这里是等待时间,超时时间ttl,时间单位

(3) 超时释放-WatchDog机制

一个默认的超时时间是30*1000ms

(4) 主重一致性-multiLock

问题来源:主节点复制重节点时发生宕机,使锁失效

解决办法

总结

Redis秒杀优化

1.问题来源

流程太多,并发的能力太差

2.解决办法

使用redis

需求分析

总结

3.代码实现

新增优惠券信息到Redis中

//保存秒杀库存到Redis中
stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());

基于Lua脚本,判断秒杀库存,一人一单,决定用户是否抢购成功

--1.列表参数
--1.1 优惠券id
local voucherId = ARGV[1]
--1.2 用户id
local userId = ARGV[2]

--2. 数据key
--2.1 库存key
local stockKey = 'seckill:stock:' .. voucherId
--2.2 订单key
local orderKey = 'seckill:order:' .. voucherId

--3. 脚本业务
--3.1 判断库存是否充足 get stockKey
if(tonumber(redis.call('get',stockKey)) <= 0) then
    --3.2 库存不足,返回1
    return 1
end
--3.2 判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember',orderKey,userId) == 1) then
    --3.3 存在,说明时重复下单
    return 2
end
--3.4 扣库存 incrby stockKey -1
redis.call('incrby',stockKey,-1);
--3.5 下单(保存用户) sadd orderKey userId
redis.call('sadd',orderKey,userId)
return 0

如果抢购成功,将优惠券id和用户id封装后存入到阻塞队列

开启线程任务,不断从阻塞队列中读取信息,实现异步下单的功能

private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
//初始化脚本
static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}
//阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
//线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

@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 = orderTasks.take();
                //2.创建订单
                handleVoucherOrder(voucherOrder);
            }catch (Exception e){
                log.error("处理订单异常",e);
            }
        }
    }
}

/*
* 创建订单
* */
private void handleVoucherOrder(VoucherOrder voucherOrder) {
    //1.获取用户
    Long userId = voucherOrder.getUserId();
    //2.创建新对象
    RLock lock = redissonClient.getLock("lock:order:"+userId);
    //3.获取锁
    boolean isLock = lock.tryLock();
    //判断锁是否获取成功
    if(!isLock){
        //获取锁失败或者重试
        log.error("不允许重复下单");
        return;
    }
    try {
        proxy.createVoucherOrder(voucherOrder);
    } catch (IllegalStateException e) {
        throw new RuntimeException(e);
    } finally {
        lock.unlock();
    }
}

private IVoucherOrderService proxy;

@Override
public Result seckillVoucher(Long voucherId) {
    //获取用户
    Long userId = UserHolder.getUser().getId();
    //1.执行Lua脚本
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString());

    //2.判断结果是否为0
    int r = result.intValue();
    if(r != 0){
        //2.1 不为0,代表没有购买资格
        return Result.fail(r == 1? "库存不足":"不能重复下单");
    }
    //2.2 为0,有购买资格,把下单信息保存到阻塞队列
    VoucherOrder voucherOrder = new VoucherOrder();
    //2.3订单Id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    //2.4用户id
    voucherOrder.setUserId(userId);
    //2.5代金券id
    voucherOrder.setVoucherId(voucherId);
    //2.6放入阻塞队列,执行异步下单
    orderTasks.add(voucherOrder);

    //获取代理对象
    proxy = (IVoucherOrderService) AopContext.currentProxy();

    return Result.ok(orderId);
}

@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
    //5.一人一单
    Long userId = voucherOrder.getUserId();

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

    //6.扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherOrder).gt("stock",0).update();
    if(!success){
        //扣减失败
        log.error("不允许重复下单");
        return;
    }

    //7.创建订单
    save(voucherOrder);
}

Redis消息队列实现异步秒杀优化

1.认识消息队列

队列模型

redis的实现

2.list实现mq

详情

优缺点

  • 优点:
    • 利用Redis存储,不受限于VM内存上限
    • 基于Redis的持久化机制,数据安全性有保证
    • 可以满足消息有序性
  • 缺点:
    • 无法避免消息丢失
    • 只支持单消费者

3.PubSub实现mq

详情

示例

优缺点

  • 优点
    • 采用发布订阅模型,支持多生产、多消费
  • 缺点:
    • 不支持数据持久化
    • 无法避免消息丢失
    • 消息堆积有上限,超出时数据丢失

4.Stream实现mq

详情-生产者

详情-消费者

特点

  • 消息可回
  • 一个消息可以被多个消费者读取
  • 可以阻塞读取
  • 有消息漏读的风险

5.Stream消费者组模式

详情

创建消费者组

使用消费者组

特点

对比

6.基于Stream实现异步秒杀

需求分析

  • 创建一个Stream类型的消息队列,名为stream.orders
  • 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherld、userld、 orderld
  • 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单

代码实现

//添加消费者组,redis版本大于5.0
XGROUP CREATE stream.orders g1 0 MKSTREAM
--更新sekill.lua
--1.列表参数
--1.1 优惠券id
local voucherId = ARGV[1]
--1.2 用户id
local userId = ARGV[2]
--1.3 订单id
local orderId = ARGV[3]

--2. 数据key
--2.1 库存key
local stockKey = 'seckill:stock:' .. voucherId
--2.2 订单key
local orderKey = 'seckill:order:' .. voucherId

--3. 脚本业务
--3.1 判断库存是否充足 get stockKey
if(tonumber(redis.call('get',stockKey)) <= 0) then
    --3.2 库存不足,返回1
    return 1
end
--3.2 判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember',orderKey,userId) == 1) then
    --3.3 存在,说明时重复下单
    return 2
end
--3.4 扣库存 incrby stockKey -1
redis.call('incrby',stockKey,-1);
--3.5 下单(保存用户) sadd orderKey userId
redis.call('sadd',orderKey,userId)
--3.6 发送消息到队列中,XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0
//添加消息
@Override
public Result seckillVoucher(Long voucherId) {
    //获取用户
    Long userId = UserHolder.getUser().getId();
    //获取订单id
    long orderId = redisIdWorker.nextId("order");
    //1.执行Lua脚本
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString(),String.valueOf(orderId));

    //2.判断结果是否为0
    int r = result.intValue();
    if(r != 0){
        //2.1 不为0,代表没有购买资格
        return Result.fail(r == 1? "库存不足":"不能重复下单");
    }

    //3.获取代理对象
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    //4.返回订单id
    return Result.ok(orderId);
}
//线程池
private final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

// 运行开关
private volatile boolean running = true;

@PostConstruct
public void init() {
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

@PreDestroy
public void shutdown() {
    running = false;
    SECKILL_ORDER_EXECUTOR.shutdownNow();
}

private  class VoucherOrderHandler implements Runnable {
    String queueName = "stream.orders";
    @Override
    public void run() {
        while (running) {
            try{
                //1.获取消息队列中的订单信息 XREADGOURP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"),
                    StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                    StreamOffset.create(queueName, ReadOffset.lastConsumed())
                );
                //2.判断消息是否获取成功
                if(list == null||list.isEmpty()){
                    //2.1 如果获取失败,说明没有消息,继续下一次循环
                    continue;
                }
                //3.解析订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);

                //4.如果获取成功,可以下单
                handleVoucherOrder(voucherOrder);
                //5.ACK确认 SACK stream.orders g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
            }catch (Exception e){
                log.error("处理订单异常",e);
                handlePendingList();
            }
        }
    }

    private void handlePendingList() {
        while (true) {
            try{
                //1.获取pending-list中的订单信息 XREADGOURP GROUP g1 c1 COUNT 1 STREAMS streams.order 0
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"),
                    StreamReadOptions.empty().count(1),
                    StreamOffset.create(queueName, ReadOffset.from("0"))
                );
                //2.判断消息是否获取成功
                if(list == null||list.isEmpty()){
                    //2.1 如果获取失败,说明没有消息,继续下一次循环
                    break;
                }
                //3.解析订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);

                //4.如果获取成功,可以下单
                handleVoucherOrder(voucherOrder);
                //5.ACK确认 SACK stream.orders g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
            }catch (Exception e){
                log.error("处理订单异常",e); 
            }
        }
    }
}

4.达人探店

发布探店笔记

探店笔记类似点评网站的评价,往往是图文结合。对应的表有两个:

  • tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
  • tb_blog_comments:其他用户对探店笔记的评价

1.发布笔记需求分析

需求分析

代码实现

@Override
public Result saveBlog(Blog blog) {
    //1.获取登录用户
    UserDTO userDTO = UserHolder.getUser();
    blog.setUserId(userDTO.getId());
    //2.保存探店笔记
    boolean isSucess = save(blog);
    if(!isSucess){
        return Result.fail("博客保存失败");
    }
    //3.查询笔记作者的所有粉丝 select * from tb___follow where follow_user_id = ?
    List<Follow> follows = followService.query().eq("follow_user_id", userDTO.getId()).list();
    //4.推送笔记id给所有粉丝
    for(Follow follow : follows){
        //4.1 获取粉丝id
        Long userId = follow.getUserId();
        //4.2 推送
        String key = "feed:" + userId;
        stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
    }
    //5.返回id
    return Result.ok(blog.getId());
}

2.查看探店笔记

需求分析

代码实现

@Resource
private IUserService userService;

@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(this::queryBlogUser);
    return Result.ok(records);
}

@Override
public Result queryBlogById(Long id){
    //1.查询blog
    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());
}

点赞

1.需求分析

  • 同一个用户只能点赞一次, 再次点击则取消点赞
  • 如果当前用户已经点,则点赞按钮高亮显示
  • 实现步骤
    • 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
    • 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
    • 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
    • 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段

2.代码实现

添加isLike属性

/**
 * 是否已经点赞
*/
@TableField(exist = false)
private Boolean isLike;

Service层

@Override
public Result likeBlog(Long id){
    //1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    //2.判断当前用户是否已经点赞
    String key = RedisConstants.BLOG_LIKED_KEY + id;
    Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
    if(BooleanUtil.isFalse(isMember)){
        //3.如果未点赞,可以点赞
        //3.1 数据库点赞数+1
        boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
        //3.2 保存用户到Redis的set集合
        if(isSuccess){
            stringRedisTemplate.opsForSet().add(key, userId.toString());
        }
    }else {
        //4.如果已点赞,取消点赞
        //4.1 数据库点赞数-1
        boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
        //4.2 把用户从redis的set集合中移除
        stringRedisTemplate.opsForSet().remove(key, userId.toString());
    }

    return Result.ok();
}

同时影响点赞状态

private void isBlogLiked(Blog blog) {
    //1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    //2.判断当前用户是否已经点赞
    String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
    Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
    blog.setIsLike(BooleanUtil.isTrue(isMember));
}

点赞排行榜

1.需求分析

2.redis数据结构

3.代码实现

//把set替换成Zset
private void isBlogLiked(Blog blog) {
    //1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    //2.判断当前用户是否已经点赞
    String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    blog.setIsLike(score != null);
}

@Override
public Result likeBlog(Long id){
    //1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    //2.判断当前用户是否已经点赞
    String key = RedisConstants.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集合
        if(isSuccess){
            stringRedisTemplate.opsForZSet().add(key, userId.toString(),System.currentTimeMillis());
        }
    }else {
        //4.如果已点赞,取消点赞
        //4.1 数据库点赞数-1
        boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
        //4.2 把用户从redis的set集合中移除
        stringRedisTemplate.opsForZSet().remove(key, userId.toString());
    }

4.点赞列表查询

Controller层

@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id) {
    return blogService.queryBlogLikes(id);
}

Service层

@Override
public Result queryBlogLikes(Long id) {
    String key = RedisConstants.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查询用户,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);
}

5.好友关注

关注和取关

1.需求分析

2.代码实现

Controller层

 @PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow){
    return iFollowService.follow(followUserId,isFollow);
}

@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId){
    return iFollowService.isFollow(followUserId);
}

Service层

@Override
public Result follow(Long followUserId, Boolean isFollow) {
    //1.获取登录用户
    Long userId = UserHolder.getUser().getId();

    //2.判断是关注还是取关
    if(isFollow){
        //2.1 关注,新增数据
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        save(follow);
    }else{
        //3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
        remove(new QueryWrapper<Follow>()
               .eq("user_id",userId).eq("follow_user_id",followUserId));
    }
    return Result.ok();
}

@Override
public Result isFollow(Long followUserId) {
    //1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    //2.查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ?
    Long count = query().eq("user_id",userId).eq("follow_user_id",followUserId).count();
    //3.判断
    return Result.ok(count > 0);
}

共同关注

1.需求分析

2.代码实现

改造关注

@Override
public Result follow(Long followUserId, Boolean isFollow) {
    //1.获取登录用户
    Long userId = UserHolder.getUser().getId();

    String key = "follows:" + userId;
    //2.判断是关注还是取关
    if(isFollow){
        //2.1 关注,新增数据
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
        if(isSuccess){
            //把关注用户的id放入redis中的set集合 sadd userId followerUserId
            stringRedisTemplate.opsForSet().add(key,followUserId.toString());
        }
    }else{
        //3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
        boolean isSuccess = remove(new QueryWrapper<Follow>()
                                   .eq("user_id",userId).eq("follow_user_id",followUserId));
        //把关注的用户id从redis集合中移除
        if(isSuccess){
            stringRedisTemplate.opsForSet().remove(key,followUserId.toString());
        }
    }
    return Result.ok();
}

Controller层

@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id") Long id){
    return iFollowService.followCommons(id);
}

Service层

@Override
public Result followCommons(Long id) {
    //1.获取当前用户
    Long userId = UserHolder.getUser().getId();
    String key = "follows:" + userId;
    //2.求交集
    String key2 = "follows:" + id;
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key,key2);
    if(intersect.isEmpty()||intersect == null){
        //无交集
        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);
}

关注推送

1.Feed流推送

实现方案

2.基于推模式实现推送功能

实现滚动分页模式:因为每一条数据的角标会发生变化,所以不能使用传统的分页模式

代码实现

@Override
public Result saveBlog(Blog blog) {
    //1.获取登录用户
    UserDTO userDTO = UserHolder.getUser();
    blog.setUserId(userDTO.getId());
    //2.保存探店笔记
    boolean isSucess = save(blog);
    if(!isSucess){
        return Result.fail("博客保存失败");
    }
    //3.查询笔记作者的所有粉丝 select * from tb___follow where follow_user_id = ?
    List<Follow> follows = followService.query().eq("follow_user_id", userDTO.getId()).list();
    //4.推送笔记id给所有粉丝
    for(Follow follow : follows){
        //4.1 获取粉丝id
        Long userId = follow.getUserId();
        //4.2 推送
        String key = "feed:" + userId;
        stringRedisTemplate.opsForSet().add(key,blog.getId().toString());
    }
    //5.返回id
    return Result.ok(blog.getId());
}

3.实现滚动分页查询

需求分析

代码实现

//创建滚动分页类
@Data
public class ScrollResult {
    private List<?> list;
    private Long minTime;
    private Integer offset;
}

定义接口

@GetMapping("/of/follow")
public Result queryBlogOfFollow(
    @RequestParam("lastId") Long max,@RequestParam(value = "offset",defaultValue = "0") Integer offset){
    return blogService.queryBlogOfFollow(max,offset);
}

Service层

@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    //1.获取当前用户
    Long userId = UserHolder.getUser().getId();
    //2.查询收件箱 ZRERANGEBYSCORE key Max Min LIMIT offset count
    String key = RedisConstants.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 查询blog是否被点赞
        isBlogLiked(blog);
    }

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

    return Result.ok(r);
}

6.附近商铺搜索

Redis中GEO功能

数据结构

附近商家搜索

1.需求分析

2.单元测试

@Test
void loadShopData(){
    //1.查询店铺信息
    List<Shop> list = shopService.list();
    //2.把店铺分组,按照typeId分组,typeId一致的放到一个集合
    Map<Long,List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
    //3.分批完成写入Redis
    for (Map.Entry<Long,List<Shop>> entry : map.entrySet()) {
        //3.1 获取类型id
        Long typeId = entry.getKey();
        String key = RedisConstants.SHOP_GEO_KEY+typeId;
        //3.2 获取同类型的店铺的集合
        List<Shop> shops = entry.getValue();
        List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
        //3.3 写入Redis GEOADD key 经度 维度 member
        for (Shop shop : shops) {
            //stringRedisTemplate.opsForGeo().add(key,new Point(shop.getX(),shop.getY()),shop.getId().toString());
            locations.add(new RedisGeoCommands.GeoLocation<>(
                shop.getId().toString(),
                new Point(shop.getX(),shop.getY())
            ));
        }
        stringRedisTemplate.opsForGeo().add(key,locations);
    }
}

3.代码实现

更新pom.xml:由于不支持Redis 6.2提供的GEOSEARCH命令,需要提示其版本

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-redis</artifactId>
        </exclusion>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.6.2</version>
</dependency>
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>6.1.6.RELEASE</version>
</dependency>

更改ShopController

@GetMapping("/of/type")
public Result queryShopByType(
    @RequestParam("typeId") Integer typeId,
    @RequestParam(value = "current", defaultValue = "1") Integer current,
    @RequestParam(value = "x",required = false) Double x,
    @RequestParam(value = "y",required = false) Double y
) {
    return shopService.queryShopByType(typeId,current,x,y);
}

重写ServiceImpl

@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    //1.判断是否需要根据坐标查询
    if(x==null||y==null){
        //不需要坐标查询,直接查询数据库
        Page<Shop> page = query().eq("type_id",typeId).page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        return  Result.ok(page.getRecords());
    }
    //2.计算分页参数
    int from = (current-1)*SystemConstants.DEFAULT_PAGE_SIZE;
    int end = current*SystemConstants.DEFAULT_PAGE_SIZE;

    //3.查询Redis,按照距离排序,分页,结果: shopId,distance
    String key = RedisConstants.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);
}

7.用户签到

BitMap用法

位图概念

Redis中BitMap用法

签到功能

1.需求分析

2.代码实现

Controller

@PostMapping("/sign")
public Result sign(){
    return userService.sign();
}

Service

@Override
public Result sign() {
    //1.获取当前登录的用户
    Long userId = UserHolder.getUser().getId();
    //2.获取日期
    LocalDateTime now = LocalDateTime.now();
    //3.拼接key
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = USER_SIGN_KEY+userId+keySuffix;
    //4.获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    //5.写入redis: SETBIT key offset 1
    stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);
    return Result.ok();
}

签到统计

1.需求分析

前置知识

接口实现

2.代码实现

Controller

@GetMapping("/sign/count")
public Result signCount(){
    return userService.signCount();
}

Service

@Override
public Result signCount() {
    //1.获取当前登录的用户
    Long userId = UserHolder.getUser().getId();
    //2.获取日期
    LocalDateTime now = LocalDateTime.now();
    //3.拼接key
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = USER_SIGN_KEY+userId+keySuffix;
    //4.获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    //5.获取本月截至到今天的所有签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET ul4 0
    List<Long> result = stringRedisTemplate.opsForValue().bitField(
        key, BitFieldSubCommands.create()
        .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(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位
        if((num & 1)==0){
            //如果为0,说明未签到,结束
            break;
        }else{
            //如果不为0,说明已签到,计数器+1
            count++;
        }
        //把数字右移一位,抛弃最后一个bit位,继续下一个bit位
        num >>>=1;
    }
    return Result.ok(count);
}

8.UV统计

UV与PV

HyperLogLog

单元测试

@Test
void testHyperLogLog(){
    String[] values = new String[1000];
    int j = 0;
    for(int i=0;i<1000000;i++){
        j = i%1000;
        values[j] = "user_"+i;
        if(j == 999){
            //发送Redis
            stringRedisTemplate.opsForHyperLogLog().add("hl2",values);
        }
    }
    //统计数量
    Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
    System.out.println("count = " + count);
}