小黑书板块

 第一天:

短信验证登录

发送验证码的api可以使用腾讯或者阿里的,自行付费即可,下面是主要的service层的代码。由于controller层就单纯调用方法,这里进行省去。

验证码和登录token都保存在redis中,并且设置合适的TTL,避免redis存储过多的信息。

 public Result sendCode(String phone, HttpSession session) {
        //对手机号进行校验
        if (RegexUtils.isPhoneInvalid(phone)) {
            //不符合
            return Result.fail("手机号格式有误!");
        }

        //符合 生成验证码
        String code = RandomUtil.randomNumbers(6);

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

        //发送验证码
        System.out.println("验证码为:" + code);

        return Result.ok();
    }

用户登录 

 public Result login(LoginFormDTO loginForm, HttpSession session) {
        //根据提交的手机号和验证码进行验证
        String phone = loginForm.getPhone();
        String code = loginForm.getCode();
        //从redis中获取验证码
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式有误!");
        }
        if (cacheCode == null || !cacheCode.equals(code)) {
            return Result.fail("验证码有误!");
        }

        //根据手机号码查询用户
        User user = query().eq("phone", phone).one();

        //判断用户是否存在
        if (user == null) {
            //没查到用户 将数据写入数据库
            user = creatUserWithPhone(phone);
        }

        //将用户保存到redis
        String token = UUID.randomUUID(true).toString(true);
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create().setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fildValue) -> fildValue.toString()));
        stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);
        stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.SECONDS);

        return Result.ok(token);
    }
//创建用户
    private User creatUserWithPhone(String phone){
        User user = new User();
        user.setPhone(phone);
        user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX +RandomUtil.randomString(10));
        save(user);
        return user;
    }

由于前面的代码限制,在每次登录成功之后,便开始计时,等到达token的过期时间后,无论用户当时是否正在操作,系统都将强行把用户踢出。所有后面添加一个拦截器,用于每次用户操作后,都将TTL进行刷新。

public class RefreshTokenIntercepor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenIntercepor(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中的用户
        String key = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);


        //3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }

        //5.将查询到的hash数据转为userdto对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        //6.存在 保存用户信息
        UserHolder.saveUser(userDTO);

        //刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.SECONDS);
        //7.放行
        return true;
    }

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

 商品类型的展示

由于这类数据的不易变性和常访问性,所以将这些数据保存在redis中,方便用户每次访问,提供性能的同时,增加了安全性。具体的逻辑思路在代码中已经明确写出。

 public Result getTypeList() {
        //查询redis中是否有类型数据
        String key = "type";
        String typeJson = stringRedisTemplate.opsForValue().get(key);
        //有 直接返回
        if (StrUtil.isNotBlank(typeJson)){
            List<ShopType> shopTypes = JSONUtil.toList(typeJson, ShopType.class);
            return Result.ok(shopTypes);
        }

        //没有 查询数据库
        List<ShopType> typeList =typeService.query().orderByAsc("sort").list();

        //数据库没有数据 返回无数据
        if(typeList==null){
            return Result.fail("无类型数据!");
        }
        //数据库有数据  将数据写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(typeList));
        //返回
        return Result.ok(typeList);
    }

商品展示 

当用户量达到很大的数量时,如果由于redis中没有该数据而导致大量的线程同时去查询数据库,造成数据库性能消耗过大,甚至宕机的危险。所有在这考虑加入一个锁去进行一个控制。能获取到锁的线程才能区查询数据库,而没有获取到锁的线程则返回之前的旧数据。在redis中有个关键字“setnx”,使在该key没有释放时没有任何线程能对该key进行操作,所以下面用该方法进行。

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

    //删除锁
    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }
public Result queryById(Long id) {
        //1.根据id从redis中查询数据
        String key = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        //2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            //3.存在 直接返回
            Shop shop = JSONUtil.toBean(shopJson,Shop.class);
            return Result.ok(shop);
        }

        //4.不存在 查询数据库
        Shop shop = getById(id);
        if (shop == null) {
            return Result.fail("无此数据!");
        }

        //5.存在 写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
        return Result.ok(shop);
    }

第二天:

实现商铺缓存和数据库数据一致性

缓存更新策略:这里采用主动更新策略,并且操作是先更新数据库,再删除缓存,具体缘由可以查看redis专题这篇文章。

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

商铺时可能导致的缓存击穿问题

利用互斥锁解决缓存击穿问题

在某个热点key突然失效的情况下,大量的请求由于redis中已经没有,这时会导致大量请求到数据库,所有这里使用互斥锁来解决  

 public Shop queryWithPassThrough(Long id) {
      //1.根据id从redis中查询数据
        String key = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        //2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            //3.存在 直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }

        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 成功 根据id查询数据库
            shop = getById(id);

            //5.不存在 查询数据库
            if (shop == null) {
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }

            //6.存在 写入redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            //7.释放互斥锁
            unLock(lockKey);
        }

        return shop;
    }
         利用设置逻辑过期时间实现
public Shop queryWithLogicalExpire(Long id) {
        //1.根据id从redis中查询数据
        String key = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        //2.判断缓存是否存在
        if (StrUtil.isBlank(shopJson)) {
            //3.不存在 直接返回空
            return null;
        }

        //4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();

        //5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return shop;
        }

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

第三天:

 全局唯一id生成器

当用户抢购时,就会生成订单并保存到tb voucher order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
        id的规律性太明显
        受单表数据量的限制

  public long nextId(String keyPerfix) {
        //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"));
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPerfix + ":" + date);

        //3.拼接并返回

        return timestamp<<COUNT_BITS |  count ;
    }

    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
        long second = time.toEpochSecond(ZoneOffset.UTC);
        System.out.println("second = " + second);
    }

实现优惠券秒杀下单

下单时需要判断两点
        1.秒杀是否开始或结束,如果尚未开始或已经结束则无法下单

        2.库存是否充足,不足则无法下单

  @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券
        SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);

        //2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //3.未开始 返回异常
            return Result.fail("活动还未开始!");
        }

        //4.开始 判断库存是否充足
        Integer stock = voucher.getStock();
        if (stock < 1) {
            //5.不充足 返回错误信息
            return Result.fail("手慢了 已经被抢完了!");
        }

        //6.充足 扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId).update();
        if (!success) {
            return Result.fail("手慢了 已经被抢完了!");
        }

        //7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

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

下面使用jmeter对其进行多线程测试

 

发现我们库存原本为100的优惠券现在剩下-9张,这就是我们所谓的“超卖”问题。 

这里我们考虑使用乐观锁的方式,所谓乐观锁就是认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据。如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

  • 版本号法
  • CAS法

 所以其实真正需要修改的也就只有第六点

 //6.充足 扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId).eq("stock",voucher.getStock())
                .update();
        if (!success) {
            return Result.fail("手慢了 已经被抢完了!");
        }

 这里是超卖问题确实解决了,但300个线程却只卖了40个出去,又出现了问题。乐观锁的成功率太低,后面对他进行一个改良。也就是说不用限制库存必须是上次查到的值,只要库存>0就能继续。

  //6.充足 扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId).gt("stock",0)
                .update();
        if (!success) {
            return Result.fail("手慢了 已经被抢完了!");
        }

现在问题是没有了,但是一看数据库,发现一个人居然可以无限量对秒杀券进行抢购,这在实际生活中肯定是不行的,下面将实现一人一单。

实现起来也很简单,只需要对用户id和优惠券id进行and查询,如果能查出数据,说明该id已经有了对应的优惠券,查不出才允许它创建订单。

 //7.一人一单
        Long userId = UserHolder.getUser().getId();

        //7.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //7.2.判断是否存在
        //7.3.存在 返回错误信息
        if(count>0){
            return Result.fail("一个人只能买一单哦!");
        }

 然后老规矩使用jmeter设置400线程同时测试,400个请求是同一个用户,下面是测试结果

OK,一看到这个结果就悟了,多线程,多个请求同时查到时,结果都为0,都认为可以进行接下来的操作,然后都去对库存进行删减。 

将需要加锁的内容单独拎出来,然后给它加上事务

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


        //7.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //6.2.判断是否存在
        //6.3.存在 返回错误信息
        if (count > 0) {
            return Result.fail("一个人只能买一单哦!");
        }

        //7.充足 扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId).gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("手慢了 已经被抢完了!");
        }

        //8.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        //9.返回
        return Result.ok(orderId);
    }
 Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            //获取代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }

在单线程的情况下,的确发现上的锁起到了效果。下面使用Idea的自带功能,使该程序变成多线程情况下。

发现两条线程都获取到了数据,并且对库存进行了删减。为什么会导致这样的情况发生呢?

因为在使用集群部署时候,会产生一个全新的JVM对象,它会有自己的堆栈,也有自己的锁监视器。也就是说,锁只在同一个JVM的情况下才能生效。

 下面就会涉及到分布式锁的知识了,感兴趣的朋友可以看看我的另外一篇文章Redis专题 里面有专门讲解到Redis实现分布式锁的知识。

public class SimpleRedisLock implements ILock {


    private static final String KEY_PREFIX = "lock:";
    private static final String ID_FREFIX = UUID.randomUUID().toString(true);

    private StringRedisTemplate stringRedisTemplate;
    private String name;

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

    @Override
    public boolean tryLock(long timeoutSec) {
        //获取线程标识
        String threadId = ID_FREFIX + Thread.currentThread().getId();
        //获取锁
        String key = KEY_PREFIX + name;
        Boolean seccess = stringRedisTemplate.opsForValue()
                .setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);

        return Boolean.TRUE.equals(seccess);
    }

    @Override
    public void unlock() {
        String key = KEY_PREFIX + name;
        //获取线程标识
        String threadId = ID_FREFIX + Thread.currentThread().getId();
        //获取锁中的标识
        String id = stringRedisTemplate.opsForValue().get(key);
        //判断标识是否一致
        if (threadId.equals(id)){
            //释放锁
            stringRedisTemplate.delete(key);
        }

    }
}
 Long userId = UserHolder.getUser().getId();
        //创建锁对象
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        //获取锁
        boolean isLock = lock.tryLock(5);
        //判断是否获取锁成功
        if (!isLock){
            return Result.fail("不允许重复下单");
        }

        //获取代理对象
        try {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            lock.unlock();
        }

上面使用了UUID对锁进行了限制,防止因为线程阻塞,一个线程将另外一个线程的锁开了的情况。

Lua脚本

确保释放锁的原子性操作,考虑加入Lua脚本,来使数据保持一致。

释放锁的业务流程是这样的:

  1. 获取锁中的线程标示
  2. 判断是否与指定的标示(当前线程标示)一致
  3. 如果一致则释放锁(删除)
  4. 如果不一致则什么都不做

然后用Lua脚本来实现上面的业务逻辑

if (redis.call('get', KEYS[1]) == ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0
 @Override
    public void unlock() {
        //调用Lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX+name),
                ID_FREFIX+Thread.currentThread().getId());

    }

基于setnx实现的分布式锁存在的问题

  • 不可重入:同一个线程无法多次获取同一把锁
  • 不可重试:获取锁只尝试一次就返回false,没有重试机制
  • 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
  • 主从一致性:如果Redis提供了主从集群主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现

后面决定使用Redisson中的方法来解决问题啦! 

第四天

Redis优化秒杀:

  1. 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
  2. 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  3. 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
  4. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
 @Transactional
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);
        //保存秒杀的库存到redis
        String key=SECKILL_STOCK_KEY+voucher.getId();
        stringRedisTemplate.opsForValue().set(key,voucher.getStock().toString());
    }

 然后经过验证,确实加入成功

下面是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.存在,说明是重复下单,返回2
    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

  private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        SECKILL_SCRIPT=new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

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

        );
        //2.判断结果是否为0
        int r = result.intValue();
        if (result!=0){
            //2.1.否 返回异常信息
            return Result.fail(r==1?"库存不足":"一个人只能抢一单");
        }
        //2.2.将优惠券id、用户id和订单id存入阻塞队列
        long orderId = redisIdWorker.nextId("order");
        // TODO 保存阻塞队列

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

发布探店笔记

老规矩,在Contorller层主要引用方法,具体业务放在Service层来写

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

@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private IUserService userService;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

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

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

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

    @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());
        //3.如果未点赞 可以点赞
        if (score == null) {
            //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();
            if (isSuccess) {
                //4.2.把用户从redis中移除
                stringRedisTemplate.opsForZSet().remove(key, userId.toString());
            }
        }
        return Result.ok();
    }

    @Override
    public Result queryBlogLikes(Long id) {
        //1.查询top5的点赞用户
        String key = BLOG_LIKED_KEY + id;
        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查询用户
        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 queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }
}

关注和取关

这个功能最终也就是两个需求

  • 关注和取关接口
  • 判断是否关注的接口
  @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        //1.获取登录用户
        Long userId = UserHolder.getUser().getId();

        //1.判断到底是关注还是取关
        if (isFollow) {
            //2.关注 新增数据
            Follow follow = new Follow();
            follow.setFollowUserId(followUserId);
            follow.setUserId(userId);
            save(follow);
        } else {
            //3.取关 删除数据
             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();

        //1.查询是否关注
        Integer count = query()
                .eq("user_id",userId).eq("follow_user_id",followUserId).count();
        //3.判断
        return Result.ok(count>0);
    }

共同关注

  @GetMapping("/of/user")
    public Result queryBlogByUserId(
            @RequestParam(value = "current", defaultValue = "1") Integer current,
            @RequestParam("id") Long id) {
        // 根据用户查询
        Page<Blog> page = blogService.query()
                .eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        return Result.ok(records);
    }
   @GetMapping("/{id}")
    public Result queryUserById(@PathVariable("id") Long userId){
        // 查询详情
        User user = userService.getById(userId);
        if (user == null) {
            return Result.ok();
        }
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        // 返回
        return Result.ok(userDTO);
    }

关注推送

关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。

Feed流产品有两种常见模式:

  • Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈

                优点:信息全面,不会有缺失。并且实现也相对简单.

                缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低

  • 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户

                优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
                缺点:如果算法不精准,可能起到反作用

feed流的实现方案

  • 推模式
  • 拉模式
  • 推拉结合模式

针对不同的情况采用不同的实现方案。

所以这里的需求就是:

  1. 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱收件箱
  2. 满足可以根据时间戳排序,必须用Redis的数据结构实现
  3. 查询收件箱数据时,可以实现分页查询
 @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.查询笔记作者的所有粉丝
        List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
        //4.推送笔记id给粉丝
        for (Follow follow : follows) {
            Long userId = follow.getUserId();
            String key = FEED_KEY + userId;
            stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
        }

        //5.返回id
        return Result.ok(blog.getId());

实现关注推送页面的分页查询 

 @Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        //1.获取当前用户
        Long userId = UserHolder.getUser().getId();

        //2.查询收件箱
        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.解析数据
        List<Long> ids = new ArrayList<>(typedTuples.size());
        long minTime = 0;
        int os=1;
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
            //4.1.获取id
            ids.add(Long.valueOf(typedTuple.getValue()));
            //4.2.获取分数(时间戳)
            long time = typedTuple.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);
        }
        //6.封装并返回
        ScrollResult r = new ScrollResult();
        r.setList(blogs);
        r.setOffset(os);
        r.setMinTime(minTime);
        return Result.ok(r);
    }

附近商户

这里采用Redis中的GEO数据结构

  • GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
  • GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
  • GEODIST:计算指定的两个点之间的距离并返回
  • GEOHASH:将指定member的坐标转为hash字符串形式并返回
  • GEOPOS:返回指定member的坐标
  • GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
  • GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
  • GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。6.2.新功能

附近商户搜索 

按照商户类型做分组,类型相同的商户作为同一组,以typeld为key存入同一个GEO集合中即可

先通过测试方法,将数据库中的商铺信息存入redis中

 @Test
    void loadShopData() {
        //1.查询店铺信息
        List<Shop> list = shopService.list();
        //2.把店铺分组 按照typeId分组 id一致的放到一个集合
        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 = SHOP_GEO_KEY + typeId;
            //3.2.获取同类型的店铺的集合
            List<Shop> value = entry.getValue();
            List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
            //3.3.写入redis
            for (Shop shop : value) {
//                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);
        }
    }

下面是具体的业务逻辑实现 

 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 = 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);

    }

用户签到

我们按月来统计用户签到信息,签到记录为1,未签到则记录为0

把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。 

  public Result sige() {
        //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 SELECT key offset 1
        stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
        return Result.ok();
    }

然后就是他的附加功能,也就是统计连续签到数

 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位
            if ((num & 1) == 0) {
                break;
            } else {
                count++;
            }
            num >>>= 1;
        }
        return Result.ok(count);
    }

  • 15
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值