1 黑马点评项目

1.1 短信登陆

1.1.1 短信登陆简介

session共享问题:多台服务器并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。

  • 在进行短信登录时,运用redis的String数据结构把手机号作为key,验证码作为value进行存储。
  • 查询用户获得用户信息后,运用redis的hash结构,用token当做key存储(token的意思是“令牌”,是服务器生成的一段加密字符串),用户信息作为一个一个hash存储。
    为什么不用String数据结构” />1.1.2 校验登录状态

    主要逻辑:验证手机号格式 如果不符合,返回错误信息,如果符合生成验证码,就运用redis的String数据结构对手机号和验证码进行存储 redis存储一般有个公共前缀并且设置有效时间 一般两分钟 最后返回ok;

    //Slfg4日志注解public Result sendCode(String phone, HttpSession session) {//1:先验证手机号格式不符合就返回错误信息if(RegexUtils.isPhoneInvalid(phone)){//2:如果不符合,返回错误信息return Result.fail("手机号格式错误");}//4:生成验证码String code = RandomUtil.randomNumbers(6); //保存验证码到redis phone作为key 并且有一个公共前缀stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY +phone,code,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);//6:发送验证码log.debug("验证码是{}",code);return Result.ok();}

    1.1.3短信验证码登录和注册

    主要逻辑:前端给手机号,以手机号从redis里面拿出验证码,比较验证码,如果不等则返回错误”验证码不正确”,如果相等看看是否被注册过,如果没有被注册过,需要插入数据,没注册过的话直接返回UserDto对象(注:UserDto对象相比User对象少了一些敏感信息,例如:密码),然后将对象存储到redis的hash结构里面,并设置有效期 一般为30分钟

    @Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {//获取手机号String phone = loginForm.getPhone();if(RegexUtils.isPhoneInvalid(phone)){//2:如果不符合,返回错误信息return Result.fail("手机号格式错误");}//从reids中拿出验证码String code = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);if(loginForm.getPhone().equals(code)){//如果验证码错误直接返回falsereturn Result.fail("验证码不正确");}//如果正确 在确定手机号是否已经被注册过User user = query().eq("phone", phone).one();//生成token用hutool工具类生成的uuid toString(true)可以把uuid中的-去掉String token = UUID.randomUUID().toString(true);if(user==null){//没有注册过新建并插入新数据user=CreateNewUser(phone);}//hutool工具类 BeanutilUserDTO userDTO= BeanUtil.copyProperties(user, UserDTO.class);//运用redis中的map数据结构存储userDto对象Map<String,String> map=newHashMap<>();map.put("id",userDTO.getId().toString());map.put("nickName",userDTO.getNickName());map.put("icon",userDTO.getIcon());stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY+token,map);//设置时间一般是30分钟不进行操作,就会失效stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);return Result.ok(token);}private User CreateNewUser(String phone) { User user=new User(); user.setPhone(phone); user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(4)); save(user); returnuser;}

    1.1.4 拦截器的实现

    主要逻辑:一个拦截器是更新拦截器,主要是更新token的有效期,一个拦截不合法的路径,更新拦截器首先获得token对象 如果token为空直接放行。若不为空的话token刷新token的有效期,然后用token从redis里面拿出UserDTO的map对象,然后把map对象转换为UserDTO对象,存入ThreadLocal域中。在拦截器执行之后将TheadLocal域中的对象释放掉,避免发生内存泄漏.一个拦截器只用判断ThreadLocal域中有没有UserDTO对象,如果有则放行,如果没有就拦截.

    //更新拦截器 主要是更新token有效期 另外拦截器不是spring管理的bean //里面不能用自动注入注解 需要用构造方法public class RefreshInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//从对象头获得tokenString token = request.getHeader("authorization");if(StrUtil.isBlank(token)){return true;}//若不为空放行,并且把用户放进TheadLocal并且把时间重置为30分钟Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY+token);if(map.isEmpty()){return true;}//hutool工具类 将map转换为实体类对象UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);UserHolder.saveUser(userDTO);stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+token,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { //释放Thread中的user类 避免内存泄露UserHolder.removeUser();}}//目的是拦截不合法的路径public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {System.out.println("执行拦截器");System.out.println(UserHolder.getUser());if(UserHolder.getUser()==null){ //状态码401 表示没授权 response.setStatus(401); return false; } return true;}}

    拦截器的配置

    @Configurationpublic class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Override//登录拦截器public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns( "/shop/**", "/voucher/**", "/shop-type/**", "upload/**", "/blog/hot", "/user/code", "/user/login").order(1);//刷新放行拦截器registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);}}

    1.2 商户查询缓存

    1.2.1 redis缓存简介

    缓存就是数据交换的缓冲区,是存储数据的临时地方,一般读写性能比较高

    缓存的作用” />1.2.2添加商户缓存

    具体流程:根据前端返回的id的数据,查商品信息,接收到id后,先从redis里面拿数据,如果有直接返回,如果没有在从数据库里面拿商品信息,如果没有报错商品不存在,如果有先往redis缓存里存入数据并且设置有效期(避免缓存数据与数据库的数据长期不一致)。

    @Overridepublic Result queryShopById(Long id) {//根据id看看redis有没有缓存String shopString = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);if(StrUtil.isNotBlank(shopString)){//如果有 直接返回//hutool工具类Shop shop = JSONUtil.toBean(shopString, Shop.class);return Result.ok(shop);}//如果没有从数据库查Shop shop = getById(id);if(shop==null){//数据库没有 返回404 没有该商品return Result.fail("该店铺不存在");}//数据库有 往redis插入数据 并且返回数据stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);}

    1.2.3 缓存更新策略

    操作缓存和数据库有三个问题” />

    具体流程:首先获得前端给的修改的数据 ,判断id是否为空,为空直接返回错误,不为空就先更新数据库,在删除redis的缓存

    @Override@Transactional//事务注解public Result updateShop(Shop shop) {Long id = shop.getId();if(id==null){//先检查id是否为空return Result.fail("商品id不能为空");}//先更新数据库updateById(shop);//在删除缓存stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+id); return Result.ok();}

    1.2.4解决缓存穿透

    具体流程 如果没有该商品 则往redis插入一个空字符串,设置短的有效期,下一次如果在redis里面查的是空字符串的话,则直接返回商品不存在

    //缓存穿透解决方案if(StrUtil.isNotBlank(shopString)){//如果有 直接返回 Shop shop = JSONUtil.toBean(shopString, Shop.class); return Result.ok(shop); }//判断字符串不为null 则为一个空字符串 直接返回404 不用经过数据库查询if("".equals(shopString)){return Result.fail("该商铺不存在");}if(shop==null){ //数据库没有 则往redis插入一个空字符串,并且设置一个短的有效期stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);return Result.fail("该店铺不存在"); }

    1.2.5解决缓存击穿

    缓存击穿问题也叫做热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数请求在瞬间给数据库带来巨大的冲击

    常见的解决方案有两种:

    • 互斥锁
    • 逻辑过期

    互斥锁解决
    具体流程:当redis里面查不到之后,先上锁,锁用的是redis的String数据结构,如果上锁失败,先睡眠,在重新去获得数据.如果上锁成功,进行第二次从redis里面查,如果还查不到,从数据库查,并且插入reids数据,释放锁,返回数据.

    private Shop queryWithMutex(Long id){//获得keyString key=RedisConstants.CACHE_SHOP_KEY + id;//从redis里面获得数据String shopString = stringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(shopString)){Shop shop = JSONUtil.toBean(shopString, Shop.class);return shop;}if("".equals(shopString)){return null;}//获得锁的keyString lockKey = RedisConstants.LOCK_SHOP_KEY + id;Shop shop=null;try {//尝试获得锁if (!tryLock(lockKey)) {//如果没有获的 睡眠 50ms 重新获取值Thread.sleep(RedisConstants.LOCK_SLEEP_TTL);return queryWithMutex(id);}//如果获得锁 进行第二次 从redis里面获得数据String string = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isNotBlank(string)) {shop = JSONUtil.toBean(string, Shop.class);return shop;}if ("".equals(string)) {return null;}//第二次没获得 从数据库查shop = getById(id);//没查到赋空字符串if (shop == null) {stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}//查到则直接往redis里面插入值stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);}catch (InterruptedException e){throw new RuntimeException(e);}finally {//释放锁unLock(lockKey);}return shop;}

    逻辑过期(感觉挺重要)

    具体流程:一般这种热点key在使用之前需要进行预热,也就是把数据先提前送到缓存中,并设置一个逻辑时间,然后拿到id查数据,如何缓存里面没有则直接返回null,如果有则查看它的逻辑过期时间是否已经过期,如果过期则拿锁,若拿不到直接返回旧值,如果拿到了再从redis拿出来看看是否过期,如果这次没过期,则直接返回,如果依然过期,则开启一个新的线程将商铺数据重新写入redis,最后释放互斥锁

    //首先设置一个Shop和过期时间的Bean@Data//@Data相当于@Getter @Setter @RequiredArgsConstructor @ToString @EqualsAndHashCode这5个注解的合集public class RedisData {private LocalDateTime expireTime;private Object data;}
    //自定义一个线程用于开启线程private static final ExecutorService CACHE_REBUILD_EXECUTOR= new ThreadPoolExecutor(10,10,0, TimeUnit.MINUTES,new LinkedBlockingDeque<>());private Shop queryWithLogicalExpire(Long id){//从redis拿数据String shopString = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);//如果为空 直接返回空if(StrUtil.isBlank(shopString)){return null;}//不为空拿出对象和过期时间RedisData redisData = JSONUtil.toBean(shopString, RedisData.class);Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);//如果过期时间没到期 直接返回对象if(redisData.getExpireTime().isAfter(LocalDateTime.now())){return shop;}String lockKey=RedisConstants.LOCK_SHOP_KEY+id;//如果过期时间到了 尝试获取锁Boolean lock = tryLock(lockKey);if(!lock){//如果没拿到锁直接返回旧数据return shop;}//拿到锁后在进行一次验证过期时间如果这一次已经被修改了 则直接返回shopString = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);if(StrUtil.isNotBlank(shopString)){redisData = JSONUtil.toBean(shopString, RedisData.class);shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);if(redisData.getExpireTime().isAfter(LocalDateTime.now())){return shop;}}//如果没被修改 运用自定义线程池 开启一个新的线程 进行更新缓存操作CACHE_REBUILD_EXECUTOR.submit(()->{try {this.saveRedis(id,20L);}catch (Exception e){throw new RuntimeException(e);}finally {this.unLock(lockKey);}});return shop;}//逻辑过期 缓存预热public void saveRedis(Long id,Long outTime){Shop shop = getById(id);RedisData rs=new RedisData();rs.setData(shop);rs.setExpireTime(LocalDateTime.now().plusSeconds(o3utTime)); stringRedisTemplate.opsForValue() .set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(rs));}

    1.3 优惠卷秒杀

    1.3.1 Redis实现全局唯一id

    全局id生成器,一般需要满足一下几个特征:

    • 唯一性
    • 高可用
    • 高性能
    • 递增性
    • 安全性

    id的组成部分:

    • 符号位:1bit,永远为0
    • 时间戳:31bit,以秒为单位,可使用69年
    • 序列号:32bit,秒内的计数器,支持每秒产生2^32割不同的id
    @Componentpublic class RedisIdWorker {//开始的时间戳 用的是2022/10/19 的时间戳private static final long BEGIN_TIMESTAMP=1666137600L;//序列号长度private static final int COUNT_BITS=32;@Resourceprivate StringRedisTemplate stringRedisTemplate;public long nextId(String keyPrefix){//生成时间戳LocalDateTime now = LocalDateTime.now//2022-10-19T16:07:19.784197600long nowSecond = now.toEpochSecond(ZoneOffset.UTC);Long timestamp=nowSecond-BEGIN_TIMESTAMP;//生成序列号//获得当前日期,精确到天//key中有年月日,方便计算一年一月一天的销售总量String date=now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));//自增长Long count=stringRedisTemplate.opsForValue().increment("inc"+keyPrefix+":"+date);//将生成的时间戳向右移动32位 然后将序列号或到后32位return timestamp<<COUNT_BITS|count;}}
    @Resourceprivate RedisIdWorker redisIdWorker;//线程池 注意线程池里面的线程必须比CountDownLatch中定义的值多private ExecutorService ex=new ThreadPoolExecutor(500,500,0, TimeUnit.SECONDS,new LinkedBlockingDeque<>());@Testvoid m1() throws InterruptedException {//CountDownLatch允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕 CountDownLatch latch=new CountDownLatch(300); Runnable task=()-> { for (int i = 0; i < 100; i++) { long id = redisIdWorker.nextId("order"); System.out.println("id=" + id); } //执行一次用countDown减一 latch.countDown(); }; long begin=System.currentTimeMillis(); for(int i=0;i<300;i++){ ex.submit(task); }//全部执行完 才能往下执行 latch.await(); Long end=System.currentTimeMillis();System.out.println("time="+(end-begin)); }

    1.3.2 实现优惠卷秒杀一人一单功能

    public Result seckillVoucher(Long voucherId) {//获得秒杀优惠卷的idSeckillVoucher voucher = seckillVoucherService.getById(voucherId);if(voucher==null){return Result.fail("优惠卷不存在");}if(voucher.getBeginTime().isAfter(LocalDateTime.now())){return Result.fail("秒杀已经开始");}if(voucher.getEndTime().isBefore(LocalDateTime.now())){return Result.fail("秒杀已经结束");}if(voucher.getStock()<1){return Result.fail("优惠卷已经被发放完");}//加锁Long userId= UserHolder.getUser().getId();//把userId当成锁 toString() 会将id变成字符串// 但是toString的源码只是new 了一个新字符串//同个id toString()还是不同对象 所以用intern()方法 将字符串放入字符串常量池 并返回synchronized (userId.toString().intern()) {//获取代理对象(事务)处理事务失效问题 还没学到 以后解决IVoucherOrderService proxy=(IVoucherOrderService)AopContext.currentProxy();/*这个对象需要在接口中声明 并且在启动类加入@EnableAspectJAutoProxy(exposeProxy = true)注解暴露代理对象还需要加入aspectjweaver 依赖*/return proxy.createVoucherOrder(voucherId);}}@Transactionalpublic Result createVoucherOrder(Long voucherId) {//为什么不在方法里面加锁" />//因为 在释放锁之后事务spring才提交事务 释放锁 //还没提交的时候 可能另一个线程可能拿到锁//线程不安全Long userId= UserHolder.getUser().getId();//实现一人一单int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if(count>0){return Result.fail("您已经购买到了");}//前面已经判断库存是否充足//这次在判断 是一种乐观锁的机制 //运用cas机制 在进行库存加减的时候 需要再次进行判断 库存是否有boolean success = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherId)//.gt("stock",0).update();if(!success){return Result.fail("优惠卷已经被发放完");}VoucherOrder voucherOrder=new VoucherOrder();long orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);voucherOrder.setUserId(UserHolder.getUser().getId());voucherOrder.setVoucherId(voucherId);this.save(voucherOrder);return Result.ok(orderId);}

    然而上述锁只能运用在单体项目中,如果在分布式项目上并不能起到一人一单功能,所以需要分布式锁
    分布式锁:满足分布式系统或集群模式下多线程可见并且互斥的锁

    • 多进程可见
    • 高可用
    • 安全性
    • 互斥
    • 高性能 等等
    private StringRedisTemplate stringRedisTemplate;private String name;public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {this.stringRedisTemplate = stringRedisTemplate;this.name = name;}private static final String KEY_PREFIX="lock:";private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";@Override // 上锁public boolean tryLock(long timeoutSec) {//运用uuid和线程id生成 valueString threadId = ID_PREFIX+Thread.currentThread().getId();//前缀和name组成 key//设置过期时间 如果redis宕机后 锁还能等时间结束后释放避免造成死锁Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId,timeoutSec, TimeUnit.SECONDS);return BooleanUtil.isTrue(success);}@Overridepublic void unLock() {//在释放锁的时候 如果线程一 拿到锁 但是进行阻塞 然后锁失效了 //线程二拿到锁 线程一在阻塞消失后 直接删除了线程二的锁 //解决方案 lua脚本String threadID = ID_PREFIX+Thread.currentThread().getId();String id1=stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);if(threadID.equals(id1)){stringRedisTemplate.delete(KEY_PREFIX+name);}}
    --- lua脚本能保证代码执行的原子性if(redis.call('get',KEYS[1])==ARGV[1]) thenreturn redis.call('del',KEYS[1])endreturn 0
     privatestaticfinal DefaultRedisScript<Long> UNLOCK_SCRIPT;static {//初始化UNLOCK_SCRIPT=new DefaultRedisScript<>();//设置脚本位置 classPath下的资源UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));//设置返回值UNLOCK_SCRIPT.setResultType(Long.class);} public void unLock() { stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX+name), ID_PREFIX+Thread.currentThread().getId());}

    使用Redisson分布式锁

    Redisson可重入锁的原理

    Redisson的锁重试,和看门狗机制

    异步秒杀思路

    异步秒杀的主要流程:在秒杀的时候 判断库存是否充足 如果不充足 直接返回错误,如果是充足的话,将优惠卷id,用户id和订单id存入阻塞队列,另开线程进行数据库交互

    -- lua脚本在判断库存是否充足时 是原子性的 避免产生线程问题-- 优惠卷idlocal voucherId=ARGV[1]-- 1.2 用户idlocal userId=ARGV[2]-- 2.数据key-- 2.1 库存key-- .. 字符串连接符local stockKey='seckill:stock:' .. voucherIdlocal orderKey='seckill:order:' .. voucherId--3脚本业务--3.1判断库存是否充足 get stockKeyif(tonumber(redis.call('get',stockKey))<=0) thenreturn 1endif(redis.call('sismember',orderKey,userId)==1) thenreturn 2end--3.4 扣库存redis.call('incrby',stockKey,-1)redis.call('sadd',orderKey,userId)return 0
    public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private SeckillVoucherServiceImpl seckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedissonClient redissonClient; privateIVoucherOrderService proxy;/*阻塞队列特点:当一个线程尝试从队列中获取元素,没有元素,线程就会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素*/ private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024*1024);//线程池的创建  private staticfinal ExecutorService SECKILL_ORDER_EXECUTOR=Executors.newSingleThreadExecutor();privatestaticfinal DefaultRedisScript<Long> SECKILL_SCRIPT;static {//初始化SECKILL_SCRIPT=new DefaultRedisScript<>();//设置脚本位置 classPath下的资源SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));//设置返回值SECKILL_SCRIPT.setResultType(Long.class);}//spring的知识 目的是为了让 在类创建时 对这个方法进行初始话@PostConstruct//spring注解privatevoid init(){SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());} @Overridepublic Result seckillVoucher(Long voucherId){//执行lua脚本Long userId = UserHolder.getUser().getId();Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(),voucherId.toString(), userId.toString());int r=result.intValue();//判断结果为0if(r!=0){return Result.fail(r==1" />"库存不足":"不能重复下单");}// 保存阻塞队列 将新建的对象存入阻塞队列VoucherOrder voucherOrder=new VoucherOrder();long orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);voucherOrder.setUserId(userId);voucherOrder.setVoucherId(voucherId);//不为0 代表没有购买职责//处理spring事务失效问题proxy=(IVoucherOrderService) AopContext.currentProxy();//加入队列 开辟线程 orderTasks.add(voucherOrder);//直接返回return Result.ok(orderId);}private class VoucherOrderHandler implements Runnable{@Overridepublic void run() {while(true){try{//这个线程主要从阻塞队列拿出voucherOrder对象 一直循环VoucherOrder voucherOrder=orderTasks.take();handleVoucherOrder(voucherOrder);}catch (Exception e){log.error("处理订单异常",e);}}}}privatevoid handleVoucherOrder(VoucherOrder voucherOrder){//不能在线程里面拿对象了 因为线程变了Long userId=voucherOrder.getUserId();//分布式锁 获取锁 双重保障RLock lock = redissonClient.getLock("lock:order"+userId);boolean b = lock.tryLock();if(!b){log.error("不允许重复下单");return;}try{//调用方法proxy.createVoucherOrder(voucherOrder);}finally {lock.unlock();}} @Transactionalpublic void createVoucherOrder(VoucherOrder voucherOrder) {Long userId= voucherOrder.getUserId();//实现一人一单int count = query().eq("user_id", userId).eq("voucher_id",voucherOrder.getVoucherId()).count();if(count>0){log.error("用户已经购买过一次!");return;}boolean success = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherOrder.getVoucherId()).gt("stock",0).update();if(!success){log.error("库存不足");}this.save(voucherOrder);}}

    1.4点赞功能实现

    需求:

    • 同一个用户只能点赞一次,再次点击则取消点赞
    • 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)

    实现步骤:

    • 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
    • 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,一点赞过则点赞-1
    @Overridepublic Result likeBlog(Long id) {//获得登录信息Long userId = UserHolder.getUser().getId();//判断当前登录用户是否已经点赞//往redis里面存入key是前缀加上博客id,value是用户idBoolean member = stringRedisTemplate.opsForSet().isMember(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());if(BooleanUtil.isFalse(member)){//如果未点赞,可以点赞//数据库点赞+1boolean success = update().setSql("liked=liked+1").eq("id", id).update();if(success) {//保存用户到redis的set集合 //stringRedisTemplate.opsForZSet().add(RedisConstants.BLOG_LIKED_KEY + id, //userId.toString(),System.currentTimeMillis());存放在zet中stringRedisTemplate.opsForSet().add(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());}}else{//如果已点赞,取消点赞//数据库点赞数-1boolean success = update().setSql("liked=liked-1").eq("id", id).update();if(success) { //把用户从redis的set集合移除stringRedisTemplate.opsForSet().remove(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());}}return Result.ok();}//在每次查询的时候需要判断该用户是否已经点赞了这个博客private void isBlogLiked(Blog blog){// 获得这个用户Long userId = UserHolder.getUser().getId();//key前缀加博客idString key=RedisConstants.BLOG_LIKED_KEY+blog.getId();//查redis里面有没有数据 如果有 isLike返回假 如果没有 则返回真Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());blog.setIsLike(BooleanUtil.isTrue(isMember));}

    1.4.1 点赞排行榜

    在实现点赞排行榜时 不能用set集合做判断了 因为set是无序的 因此将set要改成zset 将时间戳存放到score中 实现排行

    @Overridepublic Result queryBlogLikes(Long id) {//1 查询top5的点赞用户String key=RedisConstants.BLOG_LIKED_KEY+id;Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);//解析出其中的用户idif(top5==null||top5.isEmpty()){returnResult.ok(Collections.emptyList());}//运用了jdk8中的新特性 有时间学学 将set集合中的String统统改为LongList<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());//hutool下的string工具类String idsStr = StrUtil.join(",", ids);//用in不会根据根据自己的顺序进行排序//需要用 select * from tb_user where in(5,1) order by field(id,5,1)List<UserDTO> userDTOS = userService.query().in("id",ids).last("ORDER BY FIELD(id,"+idsStr+")").list().stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());return Result.ok(userDTOS);}

    1.4.2 关注和取关功能实现

    public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {@Overridepublic Result follow(Long followUserId, Boolean isFollow) {//获得登录用户Long userId = UserHolder.getUser().getId();//判断到底是关注还是取关if(isFollow){//直接往表里插入数据 Follow follow=new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); save(follow);}else {//删除数据remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));}//关注,新增取关//取关,删除return Result.ok();}@Overridepublic Result isFollow(Long followUserId) {//查看有没有关注Long userId = UserHolder.getUser().getId();Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();return Result.ok(count>0);}

    1.4.2 共同关注

    在开发共同关注时,需要将关注和取关功能的功能改善一下 需要将用户id的key和关注的用户的id作为value 放到set 因为set可以进行查找供同拥有的value

     @Overridepublic Result followCommons(Long id) {//用户idLong userId=UserHolder.getUser().getId();//用户id keyString key=RedisConstants.FOLLOW+userId;//查找另一个用户的idString key1=RedisConstants.FOLLOW+id;//进行value查重Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key1);if(intersect==null||intersect.isEmpty()){return Result.ok(Collections.emptyList());}List<Long> ids= intersect.stream().map(Long::valueOf).collect(Collectors.toList());List<UserDTO> collect = userService.listByIds(ids).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());return Result.ok(collect);}

    1.4.3 订阅好友关注

    feed流

    拉模式:大v都有自己的发件箱,当发消息的时候会发到自己的发件箱里面,等用户上线查看收件箱,会将用户关注的所有大v的发件箱复制一份放到用户的收件箱,重新按时间戳进行排序,供用户读取!弊端:如果这个人是个变态,关注着几千多个人,成千上万个数据会复制到收件箱,耗费内存

    推模式:大v在写消息时,会将关注自己的所有人的收件箱里面写一份,供粉丝阅读。缺点:如果大v有太多粉丝,也会造成太耗费内存

    拉推结合模式:对待僵尸粉采用拉模式,对待活跃粉丝采用推模式

    本次实现使用的是推模式

    //在发布笔记时,向各个用户的收件箱发送 运用的时有序zset集合@Overridepublic Result saveBlog(Blog blog) {//获得登录用户/ UserDTO user = UserHolder.getUser();blog.setUserId(user.getId());boolean success = save(blog);if(!success){return Result.fail("新增笔记失败");}//查询该用户的所有粉丝 select * from tb_follow where follow_id ='user.getId()'List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();//long l = System.currentTimeMillis();for(Follow follow:follows){//对每个粉丝的收件箱进行推送String key=RedisConstants.FEED_KEY+follow.getUserId();stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),l);}return Result.ok(blog.getId());}
    //滚动查询的意思是 当查询的时候 本来是10条数据 分页 一页两条数据 如果这时候插入一条新数据 索引变化 分页会重复查询 数据 这时候需要滚动查询 在查询第二页数据时 记录最后一个数据的score数据 并记录 这一页score 有几个 然后根据这个score数据查第三页 @Overridepublic Result queryBlogOfFollow(Long max, Integer offset) {//获得当前用户idLong userId = UserHolder.getUser().getId();//查询收件箱 key max最大值 min最小值 limit offset偏移量 count 分页数量String key=RedisConstants.FEED_KEY+userId; //value 是blogId score 就是分数Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);//非空判断if(typedTuples==null||typedTuples.isEmpty()){return Result.ok();}//解析数据:blogId,minTime,offsetArrayList<Long> ids = new ArrayList<>(typedTuples.size());long minTime=0;int os=1;for(ZSetOperations.TypedTuple<String> tuple:typedTuples){ids.add(Long.valueOf(tuple.getValue()));long time =tuple.getScore().longValue();if(time==minTime){os++;}else{minTime=time;os=1;}}String idStr=StrUtil.join(",",ids);List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();for(Blog blog:blogs){//查询blog有关的用户queryBlogUser(blog);//查询blog是否被点赞isBlogLiked(blog);}ScrollResult r=new ScrollResult();r.setList(blogs);r.setOffset(os);r.setMinTime(minTime);return Result.ok(r);}

    1.5用户签到

    @Overridepublic Result sign() {//获取当前用户Long userId = UserHolder.getUser().getId();//获取日期LocalDateTime now=LocalDateTime.now();String keySuffix=now.format(DateTimeFormatter.ofPattern(":yyyyMM"));//拼接keyString key=RedisConstants.USER_SIGN_KEY+userId+keySuffix;//获得今天是本月第几天int dayOfMonth = now.getDayOfMonth();//写入redisstringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);return Result.ok();}

    1.5.1 查询连续签到了几天

     @Overridepublic Result signCount() {//获取当前用户Long userId = UserHolder.getUser().getId();//获取日期LocalDateTime now=LocalDateTime.now();String keySuffix=now.format(DateTimeFormatter.ofPattern(":yyyyMM"));//拼接keyString key=RedisConstants.USER_SIGN_KEY+userId+keySuffix;//获得今天是本月第几天int dayOfMonth = now.getDayOfMonth();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);}//循环遍历int count=0;while(true){//让这个数字与1做与运算,得到数字的最后的一个bitif((num&1)==0){//如果为0,说明未签到 ,结束break;}else{//如果不为0,说明已签到,计数器+1count++;}//把数字右移一位,抛给最后的比特位,继续下一个bit位num>>>=1;}return Result.ok(count);}

    总结

    jdk8的新特性学一下 spring再重新学一下