黑马点评
一、短信登陆功能
1.基于session实现
2.基于session实现登陆的问题
单体应用时用户的会话信息保存在session中,session存在于服务器端的内存中,由于前前后后用户只针对一个web服务器,所以没啥问题。但是一到了web服务器集群的环境下
(我们一般都是用Nginx做负载均衡,若是使用了轮询等这种请求分配策略),就会导致用户小a在A服务器登录了,session存在于A服务器中,但是第二次请求被分配到了B服务器,由于B服务器中没有用户小a的session会话,导致用户小a还要再登陆一次,以此类推。这样用户体验很不好。当然解决办法也有很多种,比如同一个用户分配到同一个服务处理、使用cookie保持用户会话信息等。
因此,要解决这样的问题必须满足以下条件:
- 数据共享
- 内存存储
- key、value结构
3.基于redis实现短信登陆
发送验证码:
@PostMapping("code")public Result sendCode(@RequestParam("phone") String phone, HttpSession session) { return userService.sendCode(phone,session);}@Overridepublic Result sendCode(String phone, HttpSession session) { //1.校验手机号 if (RegexUtils.isPhoneInvalid(phone)) { //2.如果不符合,返回错误信息 return Result.fail("手机号格式错误!"); } //3.符合则生成验证码 final String code = RandomUtil.randomNumbers(6); //4.保存验证码到redis stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES); //5.发送验证码 log.debug("发送短信验证码成功,验证码:{}",code); //6.返回null return Result.ok();}
验证登陆功能:
login方法会把生成的token返回给前端,浏览器会将其保存到session中。
@PostMapping("/login")public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){ return userService.login(loginForm,session);}@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) { //1.校验手机号 final String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { //2.如果不符合,返回错误信息 return Result.fail("手机号格式错误!"); } //2.校验验证码,从redis中获取 final String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone); final String code = loginForm.getCode(); if(cacheCode==null||!cacheCode.equals(code)){ //3.不一直,报错 return Result.fail("验证码错误"); } //4.一致,根据手机号查询用户 User user = query().eq("phone", phone).one(); //5.判断用户是否存在 if (user == null) { //6.不存在,创建新用户并保存 user = createUserWithPhone(phone); } //7.保存用户信息到redis中 //7.1随机生成token,作为登陆令牌 String token = UUID.randomUUID().toString(true); //7.2将User对象转为HashMap存储 UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class); final Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)->{ return fieldValue.toString(); }) ); //7.3存储 stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map); //7.4设置token有效期 stringRedisTemplate.expire(LOGIN_USER_KEY+token,3000,TimeUnit.MINUTES); //8.返回token return Result.ok(token);}private User createUserWithPhone(String phone) { User user = new User(); user.setPhone(phone); user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(5)); save(user); return user;}
这里使用redis的hash结构
存储user信息,原因是:
- 若使用String结构,以JSON字符串来保存,比较直观
- 但Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少
拦截器:
- 首先,对于每个请求,我们首先根据token判断用户是否已经登陆(是否已经保存到ThreadLocal中),如果没有登陆,放行交给登陆拦截器去做,如果已经登陆,刷新token的有效期,然后放行。
- 之后来到登陆拦截器,如果ThreadLocal没有用户,说明没有登陆,拦截,否则放行。
定义UserHolder工具类:
public class UserHolder { private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>(); public static void saveUser(UserDTO user){ tl.set(user); } public static UserDTO getUser(){ return tl.get(); } public static void removeUser(){ tl.remove(); }}
刷新token拦截器:
@Slf4jpublic class RefreshTokenInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate){ this.stringRedisTemplate = stringRedisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //1.获取请求头中的token final String token = request.getHeader("authorization"); if (token == null) { return true; } //2.获取redis中的用户 final Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token); //3.判断用户是否存在 if (userMap.isEmpty()) { return true; } //5.将查询到的Hash数据转换为UserDto对象 final UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); //6.存在,保存用户信息到ThreadLocal UserHolder.saveUser(userDTO); //7.刷新token有效期 stringRedisTemplate.expire(LOGIN_USER_KEY+token,3000, TimeUnit.MINUTES); //8.放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); }}
登陆拦截器:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //1.判断是否需要拦截(ThreadLocal中是否有用户) if(UserHolder.getUser()==null){ response.setStatus(401); return false; } //8.放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); }}
在配置类中配置拦截器:
@Configurationpublic 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","/shoppublic <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Long time, TimeUnit unit,Function<ID,R> dbFallback){ String key = keyPrefix+id; //1.从redis查询商铺缓存 String json = stringRedisTemplate.opsForValue().get(key); //2.判断是否存在 if (StrUtil.isNotBlank(json)) { //3.存在,直接返回 return JSONUtil.toBean(json, type); } //命中的是否是空值 if (json != null) { return null; } //4.不存在,根据id查询数据库 R r = dbFallback.apply(id); //5.不存在,返回错误 if(r==null){ //将空值写入reddis stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } //6.存在,写入redis this.set(key,r,time,unit); //7.返回 return r;}
b.缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
c.缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
4.基于逻辑过期解决缓存击穿问题
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type, Long time, TimeUnit unit,Function<ID,R> dbFallback){ String key = keyPrefix+id; //1.从redis查询商铺缓存 String json = stringRedisTemplate.opsForValue().get(key); //2.判断是否存在 if (StrUtil.isBlank(json)) { //3.不存在,直接返回 return null; } //4.命中,先把json反序列化 RedisData redisData = JSONUtil.toBean(json, RedisData.class); JSONObject data = (JSONObject) redisData.getData(); R r = JSONUtil.toBean(data, type); LocalDateTime expireTime = redisData.getExpireTime(); //5.判断是否过期 if(expireTime.isAfter(LocalDateTime.now())){ //5.1未过期,直接返回 return r; } //5.2已过期,需要缓存重建 //6.缓存重建 //6.1获取互斥锁 String lockkey = LOCK_SHOP_KEY + id; boolean lock = tryLock(lockkey); //6.2判断是否获取锁成功 if(lock){ //6.3成功,开启独立线程,实现缓存重建 CACHE_REBUILD_EXECUTOR.submit(()->{ try { //查询数据库 R r1 = dbFallback.apply(id); //写入redis this.setWithLogicalExpire(key,r1,time,unit); } catch (Exception e) { e.printStackTrace(); } finally { //释放锁 unlock(lockkey); } }); } //6.4返回商铺信息 return r;}
三、优惠券秒杀
1.优惠券秒杀下单
一般流程:
2.超卖问题
请求a查询库存,发现库存为1,请求b这时也来查询库存,库存也为1,然后请求a让数据库减1,这时候b查询到的仍然是1,也继续让库存减1,就会导致超卖。
超卖问题有以下几个解决方案:
乐观锁
:认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据。如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。悲观锁
:认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁
实现乐观锁主要有以下两种方法:
- 版本号法
每次更新数据库的时候按照版本查询,并且要更新版本。
- CAS
CAS是英文单词Compare And Swap
的缩写,翻译过来就是比较并替换。
CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
CAS的缺点:
CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
3.一人一单功能
要求同一个优惠券,一个用户只能下一单
这样的方式会产生并发安全问题:
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了(每个jvm都有自己的锁监视器,集群模式下各个服务器的锁不共享)。
因此,我们的解决方案就是实现一个共享的锁监视器,即:
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
4.基于redis的分布式锁
a.setnx命令
setnx = SET if Not eXists
-
将 key 的值设为 value ,当且仅当 key 不存在。
-
若给定的 key 已经存在,则 SETNX 不做任何动作
b.普通setnx分布式锁出现的问题
在某个线程获取锁执行业务时若发生阻塞,且阻塞过程中锁超时,此时另一个线程同样来请求锁,发现可以获取锁,但实际上前一个线程还没执行完。
解决方案:
- 在获取锁时存入线程标示(可以用UUID表示)
- 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
- 如果一致则释放锁
- 如果不一致则不释放锁
四、消息队列优化
(三四章先占坑)
五、达人探店
1.发布探店笔记
简单的crud
2.实现点赞功能
需求:
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
实现步骤:
- 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
- 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
- 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
3.点赞排行榜
需求:按照点赞时间先后排序,返回Top5的用户
使用SortedSet:
- 通过 ZSCORE 命令获取 SortedSet 中存储的元素的相关的 SCORE 值。
- 通过 ZRANGE 命令获取指定范围内的元素。
完整代码:
BlogController
@RestController@RequestMapping("/blog")public class BlogController { @Resource private IBlogService blogService; @PutMapping("/like/{id}") public Result likeBlog(@PathVariable("id") Long id) { return blogService.likeBlog(id); } @GetMapping("/hot") public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) { return blogService.queryHotBlog(current); } @GetMapping("/{id}") public Result queryBlogById(@PathVariable("id") String id){ return blogService.queryBlogById(id); } @GetMapping("/likes/{id}") public Result queryBlogLikes(@PathVariable("id") String id) { return blogService.queryBlogLikes(id); }}
IBlogService
public interface IBlogService extends IService<Blog> { Result queryBlogById(String id); Result queryHotBlog(Integer current); Result likeBlog(Long id); Result queryBlogLikes(String id);}
BlogServiceImpl
Servicepublic class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService { @Autowired private IUserService userService; @Autowired private StringRedisTemplate stringRedisTemplate; @Override public Result queryHotBlog(Integer current) { // 根据用户查询 Page<Blog> page = query() .orderByDesc("liked") .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE)); // 获取当前页数据 List<Blog> records = page.getRecords(); // 查询用户 records.forEach(blog -> { this.queryBlogUser(blog); this.isBlogLiked(blog); }); return Result.ok(records); } @Override public Result likeBlog(Long id) { // 1、获取登录用户 UserDTO user = UserHolder.getUser(); // 2、判断当前登录用户是否已经点赞 Double score = stringRedisTemplate.opsForZSet().score(RedisConstants.BLOG_LIKED_KEY + id, user.getId().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){ // 时间作为 key 的 score stringRedisTemplate.opsForZSet().add(RedisConstants.BLOG_LIKED_KEY + id, user.getId().toString(), System.currentTimeMillis()); } } else { // 4、如果已点赞,取消点赞 // 4.1、数据库点赞数 -1 boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update(); // 4.2、把用户从 Redis 的 set 集合移除 if(isSuccess){ stringRedisTemplate.opsForZSet().remove(RedisConstants.BLOG_LIKED_KEY + id, user.getId().toString()); } } return Result.ok(); } @Override public Result queryBlogLikes(String id) { String key = RedisConstants.BLOG_LIKED_KEY + id; // 查询 top5 的点赞用户 Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4); if(top5 == null){ return Result.ok(Collections.emptyList()); } // 解析出其中的用户id List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList()); String join = StrUtil.join(",", ids); // 根据用户id查询用户 List<UserDTO> userDTOS = userService.query().in("id", ids).last("order by filed(id, "+join+")").list() .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); 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 queryBlogById(String id) { Blog blog = getById(id); if(blog == null){ return Result.fail("笔记不存在!"); } queryBlogUser(blog); // 查询 Blog 是否被点赞 isBlogLiked(blog); return Result.ok(blog); } private void isBlogLiked(Blog blog) { UserDTO user = UserHolder.getUser(); if(user == null){ return; } Long userId = user.getId(); String key = RedisConstants.BLOG_LIKED_KEY + blog.getId(); Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); blog.setIsLike(score != null); }}
六、好友关注
1.关注和取关
基于mysql实现:
基于redis实现:
设置一个给每个用户设置一个key,利用set结构存储关注该用户的人。
代码:
@PutMapping("/{id}/{isFollow}")public Result follow(@PathVariable("id") Long followUserId,@PathVariable("isFollow") Boolean isFollow){ return followService.follow(followUserId,isFollow);}@Overridepublic Result follow(Long followUserId, Boolean isFollow) { Long userId = UserHolder.getUser().getId(); String key = "follows:" + userId; //1.判断关注还是取关 if(isFollow){ //2.关注,新增数据 Follow follow = new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); boolean success = save(follow); if(success){ //把关注用户的id,放入redis的set集合 stringRedisTemplate.opsForSet().add(key,followUserId.toString()); } }else{ //3.取关,删除数据 boolean success = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId)); //把关注的用户id从redis集合中移除 if(success) stringRedisTemplate.opsForSet().remove(key,followUserId.toString()); } return Result.ok();}
@GetMapping("/or/not/{id}")public Result isFollow(@PathVariable("id") Long followUserId){ return followService.isFollow(followUserId);}@Overridepublic Result isFollow(Long followUserId) { Long userId = UserHolder.getUser().getId(); //1.查询是否关注 Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count(); return Result.ok(count>0);}
2.共同关注
在关注点击关注用户时,用redis的set结构存储自己关注了哪些用户,然后利用集合的交集就能轻松求出共同关注的用户了。
@GetMapping("/common/{id}")public Result followCommons(@PathVariable("id") Long id){ return followService.followCommons(id);}@Overridepublic 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==null||intersect.isEmpty()){ //无交集 return Result.ok(Collections.emptyList()); } //3.解析id集合 List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList()); //4.查询用户 List<UserDTO> users = userService.listByIds(ids) .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); return Result.ok(users);}
3.关注推送
Feed流产品有两种常见模式:
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
优点:信息全面,不会有缺失。并且实现也相对简单
缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低 - 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
缺点:如果算法不精准,可能起到反作用
本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
- 拉模式
- 推模式
- 推拉结合
这里基于推模式模式实现关注推送,需求:
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
- 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
- 查询收件箱数据时,可以实现分页查询
4.实现推送功能
推送:
@PostMappingpublic Result saveBlog(@RequestBody Blog blog) { return blogService.saveBlog(blog);}@Overridepublic Result saveBlog(Blog blog) { // 1.获取登录用户 UserDTO user = UserHolder.getUser(); blog.setUserId(user.getId()); // 2.保存探店博文 boolean success = this.save(blog); if(!success) return Result.fail("新增笔记失败!"); //3.查询笔记作者的所有粉丝 List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list(); //4.推送笔记id给有所粉丝 for (Follow follow : follows) { //4.1获取粉丝id Long followId = follow.getUserId(); //4.2推送 String key = FEED_KEY+followId; stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis()); } // 返回id return Result.ok(blog.getId());}
读取:
@GetMapping("/of/follow")public Result queryBlogOfFollow( @RequestParam("lastId") Long max,@RequestParam(value = "offset",defaultValue = "0") Integer offset){ return blogService.queryBlogOfFollow(max,offset);}@Overridepublic 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); if(typedTuples==null||typedTuples.isEmpty()) return Result.ok(); //3.解析数据:blogId,minTime(时间戳),offset List<Long> ids = new ArrayList<>(typedTuples.size()); long minTime = 0; int os = 1; for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { //3.1查询id String idStr = tuple.getValue(); ids.add(Long.valueOf(idStr)); //4.2获取分数(时间戳) if(tuple.getScore().longValue()==minTime){ os++; }else os = 1; minTime = tuple.getScore().longValue(); } //4.根据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) { queryBlogUser(blog); isBlogLiked(blog); } //5.封装并返回 ScrollResult r = new ScrollResult(); r.setList(blogs); r.setOffset(os); r.setMinTime(minTime); return Result.ok(r);}
七、签到功能
1.数据库实现
2.redis实现
3.具体代码
@PostMapping("/sign")public Result sign(){ return userService.sign();} @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() - 1; //5.写入redis stringRedisTemplate.opsForValue().setBit(key,dayOfMonth,true); return Result.ok(); }
来源地址:https://blog.csdn.net/qq_45733304/article/details/126443684