赞
踩
登录包括短信验证码的发送,然后是基于短信验证码的登录,最后是对登录状态的校验。
发送短信验证码
public Result sendCode(String phone, HttpSession session) { //1.校验手机号:利用util下RegexUtils进行正则验证 if (RegexUtils.isPhoneInvalid(phone)) { // 2.如果不符合,返回错误信息 return Result.fail("手机号格式错误!"); } // 3.符合,生成验证码:导入hutool依赖,内有RandomUtil String code = RandomUtil.randomNumbers(6); // 4.保存验证码到 session session.setAttribute("code",code); // 5.发送验证码 log.debug("发送短信验证码成功,验证码:{}", code); // 返回ok return Result.ok(); }
短信验证码登录、注册
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { //1.校验手机号 String phone = loginForm.getPhone(); if(RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式错误!"); } //2.校验验证码 Object cacheCode = session.getAttribute("code"); String code = loginForm.getCode(); if(code==null||!cacheCode.toString().equals(code)){ //3.不一致,报错 return Result.fail("验证码错误!"); } //4.一致,根据手机号查询用户(需要写对应的单表查询方法:select * from tb_user where phone = #{phone}) User user = query().eq("phone", phone).one(); if(user==null){ //5.注册用户 user.setPhone(phone); user.setNickName("user_"+RandomUtil.randomString(10)); //保存用户 save(user); } //6.存入session,需要隐藏用户敏感信息,不能直接存user //session.setAttribute("user",user); session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class)); return Result.ok(); }
校验登录状态
ThreadLocal其实就是一个线程域对象。在我们的业务当中,每一个请求到达我们的微服务,它都会是一个独立的线程,如果说,我们没有用ThreadLocal,而是直接把数据保存到一个本地变量,那就会可能出现多线程并发修改的一个安全问题,而ThreadLocal呢,它会将这个数据保存到每一个线程的内部,在线程内部创建一个map来去保存,这样一来每一个线程都有自己独立的存储空间,那每一个请求来了以后,都会有自己的空间,相互之间没有干扰。然后我们再去放行,那后续的所有的业务都可以从这里边去取出自己的用户信息。
基于session的短信登录会出现session共享的问题:多台Tomcat无法共享session存储空间,当请求切换到不同tomcat服务时会导致数据丢失的问题。
将来为了应对并发,肯定是要做水平扩展,部署多个tomcat形成负载均衡的集群。当请求进入Nginx,它会做一个负载均衡,在多台tomcat进行轮询。每一个tomcat都会有自己的session空间。用户请求来了以后,第一次被负载均衡到了第一台tomcat。假如说用户登录,那么用户信息保存到了这台tomcat里面去了。紧接着,用户第二次登录,请求被负载均衡到了第二台tomcat。那去获取用户信息的时候,就是空的。就会告诉用户说你没有登录,这不扯了吗?我前一秒钟刚登录完,这样用户体验就很不好。这就是所谓的session共享的问题。
session 的替代方案应该满足:数据共享;内存存储;key、value 结构(Redis 恰好就满足这些情况)
修改发送短信验证码,保存验证码用Redis的String数据结构,key是手机号,value是验证码
// 4.保存验证码到 session
// session.setAttribute("code",code);
// 4.保存验证码到 Redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
修改短信验证码登录、注册,用Redis的Hash数据结构保存用户,通过UUID随机生成token,作为登录令牌。不能直接把user存入Hash,而是要通过UserDTO隐藏用户敏感信息,然后存入
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)) { // 不一致,报错 return Result.fail("验证码错误"); } // 3.一致,根据手机号查询用户 select * from tb_user where phone = #{phone} User user = query().eq("phone", phone).one(); // 4.判断用户是否存在 if (user == null) { //5.注册用户 User newUser = new User(); newUser.setPhone(phone); newUser.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10)); save(newUser); user = newUser; } // 6.保存用户信息到 redis中 // 6.1.通过UUID随机生成token,作为登录令牌 String token = UUID.randomUUID().toString(true); // 6.2.将User对象转为HashMap存储 UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); HashMap<Object, Object> userMap = new HashMap<>(); userMap.put("id", userDTO.getId().toString()); userMap.put("nickName", userDTO.getNickName()); userMap.put("icon", userDTO.getIcon()); // 6.3.存储到redis中 String tokenKey = LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); // 6.4.设置token有效期 stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); // 7.返回token return Result.ok(token); }
修改校验登录状态
什么是缓存:
缓存的作用:
使用缓存带来的问题:
内存淘汰 | 超时剔除 | 主动更新 |
---|---|---|
不用自己维护。利用 Redis 的内存淘汰机制:当内存不足时自动淘汰部分数据。下次查询时更新缓存。 | 给缓存数据添加 TTL 时间,到期后自动删除缓存。下次查询时更新缓存。 | 编写业务逻辑,在修改数据库的同时,更新缓存。 |
业务场景
具体例子
项目中 ShopController 中给查询商铺的缓存添加超时剔除和主动更新的策略
更新商铺时,保证数据库和缓存的一致性
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
缓存穿透是指用户请求的数据在缓存和数据库中都不存在,如果不断发起这样的请求,这些请求都会打到数据库,给数据库带来巨大的压力。
常见的解决方案有两种:
1.缓存null值
2.布隆过滤
基于缓存空对象解决商铺查询的缓存穿透问题
缓存雪崩指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
1.给不同的Key的TTL添加随机值
2.利用Redis集群提高服务的可用性
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建耗时长的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
1.互斥锁:查询缓存未命中,获取互斥锁,获取到互斥锁的线程才能查询数据库重建缓存,将数据写入缓存中后,释放锁。
2.逻辑过期:查询缓存,发现逻辑时间已经过期,获取互斥锁,开启新线程;在新线程中查询数据库重建缓存,将数据写入缓存中后,释放锁;在释放锁之前,查询该数据时,都会将过期的数据返回。
当用户抢购优惠券时,就会生成订单并保存到订单表中,而订单表如果使用数据库自增ID就存在一些问题:
全局唯一 ID 生成策略用的是Redis自增id策略。Redis的String数据结构,有个Increment命令,可以实现自增。
全局唯一ID
@Component public class RedisIdWorker { //开始时间戳 private static final long BEGIN_TIMESTAMP = 1640995200L; //序列号的位数 private static final int COUNT_BITS = 32; private StringRedisTemplate stringRedisTemplate; public RedisIdWorker(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } public long nextId(String keyPrefix) { // 1.生成时间戳 LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; // 2.生成序列号 // 2.1.获取当前日期,精确到天 String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); // 2.2.自增长 long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date); // 3.拼接并返回 return timestamp << COUNT_BITS | count; } }
测试
@Resource private RedisIdWorker redisIdWorker; //线程池,500个线程 private ExecutorService es = Executors.newFixedThreadPool(500); @Test void testRedisIdWorker() throws InterruptedException { //CountDownLatch: 300个线程执行任务,每当一个线程的任务执行完,计数器-1 CountDownLatch latch = new CountDownLatch(300); //任务:每个线程生成100个id,并打印 Runnable task = () -> { for (int i = 0; i < 100; i++) { long id = redisIdWorker.nextId("order"); System.out.println("id = " + id); } latch.countDown(); }; long begin = System.currentTimeMillis(); //提交任务300次 for (int i = 0; i < 300; i++) { es.submit(task); } latch.await(); long end = System.currentTimeMillis(); System.out.println("time = " + (end - begin) ); }
秒杀下单时需要判断两点:
下单核心逻辑分析:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件,时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。
超卖问题是典型的多线程并发安全问题。线程 1 和 线程 2 都查询库存为 1(二者都未进行判断并扣减),之后线程1先判断1>0,扣减库存,这时候库存变为0。线程2也判断1>0,扣减库存,这时候库存就变成-1了。
多线程并发安全问题产生的原因就是多个线程操作共享资源,操作资源的代码有好几行,在这几行代码执行的中间,多个线程互相穿插,就出现安全问题了。
针对这一问题的常见解决方案就是加锁:而对于加锁,通常有两种解决方案:
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的处理方式有两种:版本号 和 CAS
版本号法:每当数据进行修改,版本号就会 +1。判断一个数据有没有修改过只要判断版本号有没有变化。
CAS法:在版本号法基础上进行简化,通过数据本身有没有发生变化来判断线程是否安全。既然每次更新都要更新库存和版本,那只要判断我扣减库存时的库存和之前我查询到的库存是不是一样的,一样就意味着没有修改过库存,那么此时线程就是安全的。
乐观锁
// 5. 减扣库存
boolean isSuccess = seckillVoucherService.update()
//set stock = stock - 1
.setSql("stock= stock -1")
//where voucher_id = ? and stock = ?
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update();
以上逻辑的核心含义是:只要扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
修改乐观锁,改成stock大于0
// 5. 减扣库存
boolean isSuccess = seckillVoucherService.update()
// set stock= stock - 1
.setSql("stock = stock - 1")
// where voucher_id = ? and stock > 0
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
修改秒杀业务,要求同一个优惠券,一个用户只能下一单。
具体操作逻辑如下:时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单
存在问题:和之前库存超卖问题一样,有多线程并发安全问题,多个线程穿插执行。所以需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作。
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
1、我们将服务启动两份,端口分别为8081和8082:
2、然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:
有关锁失效原因分析
由于我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,jvm的内部维护了一个锁监视器对象,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象userid是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程。
常见的分布式锁有三种:
Mysql:mysql本身有互斥锁机制,但是mysql性能一般,所以mysql作为分布式锁比较少见。
现在企业级开发中基本都使用Redis或者zookeeper作为分布式锁。
Redis:利用setnx方法,获取锁。如果key插入成功,则表示获得到了锁,如果插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
Zookeeper:zookeeper利用节点的唯一性和有序性(节点id递增)实现互斥。
实现分布式锁时需要实现两个方法:获取锁和释放锁
如果获取锁成功,还没来得及expire,Redis 就宕机了。如何保证获取锁和释放锁操作同时成功和失败,保证其原子性?
将 setnx 命令 和 ex命令 合起来:SET key value EX 超时时间 NX
消息队列(Message Queue)是存放消息的队列。最简单的消息队列模型包括3个角色:
使用队列的好处在于 解耦:所谓解耦,举一个生活中的例子就是:快递员(生产者)把快递放到快递柜里边(Message Queue)去,我们(消费者)从快递柜里边去拿东西,这就是一个异步,如果耦合,那么这个快递员相当于直接把快递交给你,这事固然好,但是万一你不在家,那么快递员就会一直等你,这就浪费了快递员的时间,所以这种思想在我们日常开发中,是非常有必要的。
在秒杀场景中:当有人抢购优惠券时,不着急真正下单,可以先判断用户有没有购买的资格,有购买的资格,不写到数据库,而是把订单的相关信息写到消息队列里去,通过队列把消息发送出去,这时候再开启一个独立的线程作为消费者,不断地从队列里获取消息,真正地完成下单,写入数据库。这样秒杀的业务就和写数据库的业务分离了,变成了异步操作,解除了耦合。
这里我们可以使用一些现成的mq,比如kafka,rabbitmq等等,但是如果没有安装mq,我们也可以直接使用redis提供的mq方案,降低我们的部署和学习成本。
同一个用户只能点赞一次,再次点击就取消点赞。
如果当前用户已经点赞,则点赞按钮高亮显示(由前端来实现,我们只要告诉前端有没有点过赞,前端判断Blog类的isLike属性是True还是False,True表示点过赞,False表示没点赞)
给 Blog 类中添加一个 isLike 字段
//是否点赞
@TableField(exist = false)
private Boolean isLike;
判断用户是否对该 Blog 点赞过
/** * 判断用户是否对该 Blog 点赞过 */ private void isBlogLiked(Blog blog) { String key = BLOG_LIKED_KEY + blog.getId(); UserDTO user = UserHolder.getUser(); if (user == null) { // 用户未登录,无需查询是否点过赞 return; } Long userId = user.getId(); Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); blog.setIsLike(BooleanUtil.isTrue(isLiked); /** * 展示 Blog 详情页(根据ID查Blog) */ @Override public Result queryById(Long id) { //1. 查询blog Blog blog = getById(id); if (blog == null) { return Result.fail("笔记不存在!"); } //2. 查询blog相关的用户 queryBlogWithUserInfo(blog); // 3.查询blog是否被点赞 isBlogLiked(blog); return Result.ok(blog); } /** * 分页查询 Blog */ @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(); // 查询blog相关的用户以及blog是否被点赞 records.forEach(blog -> { this.queryBlogWithUserInfo(blog); this.isBlogLiked(blog); }); return Result.ok(records); } }
实现点赞功能
@Override public Result likeBlog(Long id) { // 1. 判断当前登录用户是否点过赞。 Long userId = UserHolder.getUser().getId(); String key = BLOG_LIKED_KEY + id; Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); // 2. 未点过赞:点赞,数据库点赞数 +1,将用户保存到 Redis 的 Set 集合中。 if (BooleanUtil.isFalse(isLiked)) { Boolean isSucceed = update().setSql("liked = liked + 1").eq("id", id).update(); if (BooleanUtil.isTrue(isSucceed)) { stringRedisTemplate.opsForSet().add(key, userId.toString()); } } else { // 3. 已点过赞:取消赞,数据库点赞数 -1,将用户从 Redis 的 Set 集合中移除。 Boolean isSucceed = update().setSql("liked = liked - 1").eq("id", id).update(); if (BooleanUtil.isTrue(isSucceed)) { stringRedisTemplate.opsForSet().remove(key, userId.toString()); } } return Result.ok(); }
按照点赞时间先后排序,返回Top5点赞的用户。
修改点赞业务逻辑
private void isBlogLiked(Blog blog) {
// 1.获取登录用户
String key = BLOG_LIKED_KEY + blog.getId();
UserDTO user = UserHolder.getUser();
if (user == null) {
// 用户未登录,无需查询是否点过赞
return;
}
// 2.判断当前登录用户是否已经点赞
Long userId = user.getId();
//Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
//blog.setIsLike(BooleanUtil.isTrue(isLiked);
blog.setIsLike(score != null);
}
public Result likeBlog(Long id) { // 1. 判断当前登录用户是否点过赞。 Long userId = UserHolder.getUser().getId(); String key = BLOG_LIKED_KEY + id; //Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); // 2. 未点过赞:点赞,数据库点赞数 +1,将用户保存到 Redis 的 Set 集合中。 if (BooleanUtil.isFalse(isLiked)) { Boolean isSucceed = update().setSql("liked = liked + 1").eq("id", id).update(); if (BooleanUtil.isTrue(isSucceed)) { //stringRedisTemplate.opsForSet().add(key, userId.toString()); stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis()); } } else { // 3. 已点过赞:取消赞,数据库点赞数 -1,将用户从 Redis 的 Set 集合中移除。 Boolean isSucceed = update().setSql("liked = liked - 1").eq("id", id).update(); if (BooleanUtil.isTrue(isSucceed)) { //stringRedisTemplate.opsForSet().remove(key, userId.toString()); stringRedisTemplate.opsForZSet().remove(key, userId.toString()); } } return Result.ok(); }
top5点赞用户查询
@Override public Result queryBlogLikes(Long id) { String key = BLOG_LIKED_KEY + id; // 1. 查询最早五个点赞的用户 Set<String> topFive = stringRedisTemplate.opsForZSet().range(key, 0, 4); if (topFive == null || topFive.isEmpty()) { return Result.ok(Collections.emptyList()); } // 2. 解析出UserId,然后根据UserId查询user,再转化为UserDto List<Long> userIdList = topFive.stream().map(Long::valueOf).collect(Collectors.toList()); // List<UserDTO> userDTOList = userService.listByIds(userIdList) // .stream() // .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) // .collect(Collectors.toList()); List<User> users = userService.listByIds(userIdList); List<UserDTO> userDTOList =new ArrayList<>(); for (User user : users){ UserDTO userDTO = new UserDTO(); BeanUtils.copyProperties(user,userDTO); userDTOList.add(userDTO); } return Result.ok(userDTOList); }
关注讲的是用户之间的关系,是种多对多的关系,需要借助中间表,通过 tb_follow表进行表示。
关注功能需要实现两个接口:1.关注与取关的接口 2.判断是否关注的接口。
判断到底是关注还是取关,取决的是传的参数isFollow,是True就代表关注,是False就代表取关。先获取当前登录的用户,然后判断是关注还是取关,关注就新增数据,取关就删除数据。
//关注或取关 @Override public Result follow(Long followUserId, Boolean isFollow) { //1.获取当前登录用户 Long userId = UserHolder.getUser().getId(); if(isFollow){ // 2.关注,新增数据 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 = ? QueryWrapper<Follow> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("user_id",userId).eq("follow_user_id",followUserId); remove(queryWrapper); } } return Result.ok(); }
关注和取关完成之后,我们就要去判断一个用户有没有关注,就去tb_follow表查询有没有这样的一条数据,有的话就是关注了,没有的话就是没有关注。
/**
* 判断是否关注该用户
* @param followUserId
* @return
*/
@Override
public Result isFollow(Long followUserId) {
//1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
//2.查询是否已关注 select count(*) from tb_follow where user_id = #{userId} and follow_user_id = #{followUserId};
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
//3.判断是否关注
return Result.ok(count > 0);
}
在博主个人页面展示出当前用户与博主的共同好友
//关注或取关 @Override public Result follow(Long followUserId, Boolean isFollow) { //1.获取当前登录用户 Long userId = UserHolder.getUser().getId(); String followKey = "follow:" + userId; if(isFollow){ // 2.关注,新增数据 Follow follow = new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); boolean isSave = save(follow); if(isSave){ //把目标用户id放入Redis的Set集合 当前用户id 为 key,关注用户id 为 value stringRedisTemplate.opsForSet().add(followKey, followUserId.toString()); } }else { // 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ? QueryWrapper<Follow> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("user_id",userId).eq("follow_user_id",followUserId); boolean isRemove = remove(queryWrapper); if(isRemove){ //把目标用户id从Redis的Set集合移除 stringRedisTemplate.opsForSet().remove(followKey, followUserId.toString()); } } return Result.ok(); }
@Override public Result commonFollow(Long followUserId) { // 1.获取当前登录用户 Long userId = UserHolder.getUser().getId(); String followKey1 = "follow:" + userId; //获取目标用户 String followKey2 = "follow:" + followUserId; //2.求交集 Set<String> intersect = stringRedisTemplate.opsForSet().intersect(followKey1, followKey2); if(intersect==null||intersect.isEmpty()){ return Result.ok(Collections.emptyList()); } //3.解析出id集合 List<Long> userIdList = intersect.stream().map(Long::valueOf).collect(Collectors.toList()); //4.然后根据UserId查询用户,再转化为UserDto List<User> ---> List<UserDTO> // List<User> users = userService.listByIds(userIdList); // List<UserDTO> userDTOList =new ArrayList<>(); // for (User user : users){ // UserDTO userDTO = new UserDTO(); // BeanUtils.copyProperties(user,userDTO); // userDTOList.add(userDTO); // } List<UserDTO> userDTOList = userService.listByIds(userIdList) .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); return Result.ok(userDTOList); }
要去修改新增笔记的业务,在保存 Blog 到数据库的同时,也要推送消息到粉丝的收件箱。
收件箱根据时间戳排序,使用 Redis 的 SortedSet 数据结构实现。
实现滚动分页查询收件箱数据。(不能使用传统的分页,因为Feed流中的数据会不断更新,每当有新数据的时候,就会出现角标变动,读取到重复的数据,所以需要利用到滚动分页,记录每次操作的最后一条消息,从这个位置开始读取数据。)
关注推送也叫做 Feed 流。用户通过无限下拉刷新获取新的信息。
获取信息的两种模式:
在我们的业务量,个人页面中有个关注的选项卡,这里会展示出关注的人发的探店笔记。用户关注的人发布新的笔记,就会第一时间推送给用户,是基于关注的好友来做 Feed 流,因此采用的是Timeline模式。
实现Timeline模式的方案有三种:拉模式、推模式、推拉结合。
因为用户不多,这里基于推模式实现关注推送。
推送消息到粉丝的收件箱
public Result saveBlog(Blog blog) { // 1. 获取登录用户 UserDTO user = UserHolder.getUser(); blog.setUserId(user.getId()); // 2. 保存探店博文 boolean isSucceed = save(blog); if (BooleanUtil.isFalse(isSucceed)) { return Result.fail("笔记发布失败"); } // 3. 查询笔记作者的所有粉丝(select * from tb_follow where follow_user_id = ?) List<Follow> followUserList = followService.query().eq("follow_user_id", user.getId()).list(); if (followUserList.isEmpty() || followUserList == null) { return Result.ok(blog.getId()); } // 4. 推送笔记给所有粉丝 for (Follow follow : followUserList) { // 粉丝ID 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()); }
滚动分页查询
public Result queryBlogOfFollow(Long max, Integer offset) { // 1. 获取当前用户 Long userId = UserHolder.getUser().getId(); // 2. 查询收件箱 ZREVRANGEBYSCORE key max min LIMIT offset count String key = FEED_KEY + userId; Set<ZSetOperations.TypedTuple<String>> tupleSet = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2); if (tupleSet.isEmpty() || tupleSet == null) { return Result.ok(); } //3.解析数据:blogId,minTime(时间戳),offset List<Long> blogIdList = new ArrayList<>(tupleSet.size()); long minTime = 0; int nextOffset = 1; for (ZSetOperations.TypedTuple<String> tuple : tupleSet) { blogIdList.add(Long.valueOf(tuple.getValue())); // 时间戳(最后一个元素即为最小时间戳) long time = tuple.getScore().longValue(); // 假设时间戳为:5 4 4 2 2 // 5 != 0 --> minTime=5; nextOffset = 1; // 4 != 5 --> minTime=4; nextOffset = 1; // 4 == 4 --> minTime=4; nextOffset = 2; // 2 != 4 --> minTime=2; nextOffset = 1; // 2 == 2 --> minTime=2; nextOffset = 2; if (time == minTime) { nextOffset ++; } else { minTime = time; nextOffset = 1; } } // 4. 根据 ID 查询 Blog String blogIdStr = StrUtil.join(", ", blogIdList); List<Blog> blogList = lambdaQuery().in(Blog::getId, blogIdList).last("ORDER BY FIELD(id, " + blogIdStr + ")").list(); for (Blog blog : blogList) { // 完善 Blog 数据:查询并且设置与 Blog 有关的用户信息,以及 Blog 是否被该用户点赞 queryBlogWithUserInfo(blog); isBlogLiked(blog); } // 5. 封装并返回 ScrollResult scrollResult = new ScrollResult(); scrollResult.setList(blogList); scrollResult.setMinTime(minTime); scrollResult.setOffset(nextOffset); return Result.ok(scrollResult); }
假如直接用数据库表来实现签到,用户签到一次就是一条记录,若有 1000 万用户,平均每人每年的签到次数为 10 次,这张表的数据量为 1 亿条,数据库压力过大。
解决方案:用一张签到表,签到打个 ✔️ 即可,未签到打个❌。
public Result sign() {
//1.获取当前用户
Long userId = UserHolder.getUser().getId();
//2.获取日期
LocalDateTime now = LocalDateTime.now();
//3.拼接key sign:1010:202302
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
//4.获取今天是本月的第几天 (1~31,对应BitMap的offset0~30)
int dayOfMonth = now.getDayOfMonth() - 1;
//5.写入redis
stringRedisTemplate.opsForValue().setBit(key,dayOfMonth,true);
return Result.ok();
}
从最后一次签到向前统计,直到遇到第一次未签到为止;计算总的签到次数,就是连续签到天数。
Java 代码:用BitField命令获取本月到今天为止的所有数据,定义一个计数器,从后向前遍历每个 Bit 位;BitField命令获取到数据的是十进制,将它与 1 做与运算,就能得到最后一个 bit 位。随后将数字右移 1 位,下一个 bit 位就成为了最后一个 bit 位。不断地向前统计,每次获得一个非0 的数字计数器 + 1,直到遍历完所有的数据。
UV 统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到 Redis 中,数据量会非常恐怖。
单元测试,向 HyperLogLog 中添加100万条数据
@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); }
1、调用自身方法导致的事务失效
一个简单的事务失效的例子:
@Service
public class OrderServiceImpl implements OrderService {
public void update(Order order) {
updateOrder(order); // 相当于this.updateOrder(order)
}
@Transactional
public void updateOrder(Order order) {
// update order
}
}
事务可以生效,是因为Spring对当前类做了动态代理。这里调用的方法,是this.的方式调用的,我们要获取代理对象让事务生效。
1.获取代理对象让事务生效
@Service
public class OrderServiceImpl implements OrderService {
public void update(Order order) {
IOrderService proxy = (IOrderService) AopContext.currentProxy();
proxy.updateOrder(order);
}
@Transactional
public void updateOrder(Order order) {
// update order
}
}
2.在pom文件里引入aspectj依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
3.启动类上加 @EnableAspectJAutoProxy(exposeProxy = true) 暴露代理对象
最终版
public Result seckillVoucher(Long voucherId) { // 1. 根据 优惠券id 查询数据库 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); // 2. 判断秒杀是否开始或结束(未开始或已结束,返回异常结果) if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){ return Result.fail("秒杀尚未开始!"); } if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){ return Result.fail("秒杀已经结束!"); } // 3. 判断库存是否充足(不充足返回异常结果) if(seckillVoucher.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) { // 4. 一人一单(根据 优惠券id 和 用户id 查询订单;存在,则直接返回) Long userId = UserHolder.getUser().getId(); Integer count = query().eq("voucher_id", voucherId).eq("user_id", userId).count(); if (count > 0) { return Result.fail("不可重复下单!"); } // 5. 减扣库存 boolean isSuccess = seckillVoucherService.update() // set stock= stock - 1 .setSql("stock = stock - 1") // where voucher_id = ? and stock = ? .eq("voucher_id", voucherId).gt("stock", 0) .update(); if (!isSuccess) { //减扣失败 return Result.fail("库存不足!"); } // 6. 创建订单 VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); //Long userId = UserHolder.getUser().getId(); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); boolean isSaved = save(voucherOrder); if (!isSaved) { return Result.fail("下单失败!"); } // 7. 返回 订单id return Result.ok(orderId); }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。