# 日新点评 **Repository Path**: luomoxingchen/rixin-review ## Basic Information - **Project Name**: 日新点评 - **Description**: 基于黑马点评,做了下单异步流程优化 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-09-24 - **Last Updated**: 2025-12-23 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README 项目核心知识点 ### 1、基于Cache Aside解决数据一致性问题,如何解决的,并谈一谈你了解的缓存更新策略 Cache Aside是判断是否有缓存,有则直接返回,没有则查询数据库再返回并更新缓存;但面对并发场景,修改同一缓存中存有的数据,会导致数据不一致性,主要是更新缓存和更新数据库不是一个原子操作 **解决方法**:采用**超时剔除**和**主动更新** 给缓存数据添加超时时间,并修改存储信息时,先修改数据库后删除缓存 删除缓存是为了减少不必要的空间浪费,还能减少一次性能消耗(修改缓存的数据,删除比修改快) **缓存更新策略** | | 内存淘汰(Cache-Aside) | 超时删除(Read/Write Through) | 主动更新(Write Back) | | :------: | :----------------------------------------------------------: | :----------------------------------------------------------: | :--------------------------------------------: | | 说明 | 不用自己维护,Redis的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存 | 给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存 | 编写业务逻辑,在更改数据库的同时更改缓存的数据 | | 一致性 | 差 | 一般 | 好 | | 维护成本 | 无 | 低 | 高 | 低一致性需求:使用内存淘汰机制即可,例如店铺类型的查询缓存 高一致性需求:主动更新,并以超时删错作为兜底(更新的同时设置有效期),例如店铺详情查询的缓存 **延迟双删**: 为防止多线程在删除缓存后更新数据库,可能因为执行顺序将旧的数据覆盖存到缓存中,因此可以使用延迟双删,在延迟几百毫秒后删除新更新的缓存(删除可能为旧数据的缓存数据) ### 2、如何解决缓存穿透问题? 穿透:请求**缓存和数据库中都不存在的数据** **解决方法**:①缓存空对象;②使用布隆过滤器(大概原理是将所存的数据用一个二进制数通过某种算法使其上的某些数为1,这所为这个数据划分的位数数据都为1则表示该数据存在(数位1可以复用,即同一位数可以作为不同数据的确认位数使用,但不能完全重复) 其他预防方法:③增加数据复杂度,避免被猜;④做好数据格式校验(不合法请求不通过);⑤增强用户权限设定;⑥做好热点参数的限流 本项目采用:**缓存空对象**,判一下redis的json是否为空对象,是返回null,再判是否为null,不为null则返回,为空则查询数据库,没查到就设置空数据,查到就返回并设置缓存以及过期时间 ### 3、怎么解决缓存击穿问题? 缓存击穿:一个热点key失效,其上的大量请求涌入数据库 **解决方法**:①互斥锁(牺牲性能,保证原子性);②逻辑时间(性能更好,但数据并不一定真实且占用缓存空间) 本项目都写了其对应策略: - 互斥锁是使用redis的setIfAbsent指令(原子性版的sentx指令,判断加设置),其加锁key是:**UUID + 当前线程id**,值为当前线程id,**注意释放时获取值判断是否为当前线程id**;也可以使用RedissonClient的getRlock获取锁(**注意释放时要判断当前线程是否持有锁**) - 逻辑过期时间:redis中存逻辑日期,并在项目中添加RedisData类来附带逻辑时间类进行封装(每次存带有逻辑时间的类需要经过封装再存入缓存中),获取时先判断逻辑日期是否过期,没过期直接返回数据,过期则重构缓存,尝试获取互斥锁,获取成功则开启线程重构缓存,获取失败则直接返回已过期的数据,本项目设定10s ### 4、缓存雪崩的解决方式? 缓存雪崩:大量的缓存key在同一时间失效,导致多个key的请求打到数据库 **解决方案**:①添加缓存TTL时增添随机性时间(分散在不同时间段);②使用Redis集群或哨兵,设置主从关系,集群分片存储到多节点,哨兵实现主从节点故障自动转移;③添加降级限流策略;④添加多级缓存 ### 5、全局Id的使用 本项目生成秒杀订单号采用基于Redis的自增id(increment自增序列号,32位) + 时间戳(31位)+ 符号位(始终为0) 符号位0 时间戳是通过现在的纪元毫秒减去规定的纪元毫秒时间(规定的为2022-01-01 00:08:00) Redis自增id是当日的日期组合拼接,每秒能生成2^32个不同id ```java @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 timeStamp = now.toEpochSecond(ZoneOffset.UTC) - 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; } } ``` **其他全局ID策略**: 1. UUID利用JDK自带的工具类即可生成,生成的是16进制的字符串,无单调递增的特性。 2. Redis自增(每天一个key,方便统计订单量。时间戳+计数器的格式。) 3. snowflake雪花算法(不依赖于Redis,性能更好,对于时钟依赖) 4. 数据库自增 ### 6、如何解决秒杀劵超卖问题? **悲观锁**:synchronized或Lock,获取互斥锁,能保证不超卖,但性能低 **乐观锁** :两种方法 ①**版本号**,给秒杀劵字段添加version字段,当抢购成功则version + 1,每次需要update的sql判断前后version是否一致,不一致则修改失败;(update有行锁,保证version实时更新) ②**库存数量判断**:当库存stock是否与操作前所查一致,但这样**成功率低**,因此每次只需判断当前stock库存是否大于0即可,大于0则成功修改,否则失败 ![img](D:\Learning\Java\笔记图片\redis\6a5877b6520a15c240ddbcdf47090c56.png) **其他解决超卖的方法**:③分布式锁 + 分段缓存(类似ConcurrentHashMap的锁节点,将库存分散成几个缓存,通过取模来依次扣减不同缓存的值,大概思想);④redis队列解决(只能一次卖一个商品) [【并发】高并发下库存超卖问题如何解决?-阿里云开发者社区](https://developer.aliyun.com/article/1307527) ### 7、如何实现一人一单? #### 一、Redis分布式锁 + Lua脚本(判断释放锁) 通过给用户id上**分布式锁**,而不是锁整个方法(多个用户获取一个锁效率低),但在集群模式下无法锁同一用户id字符串,因此需要分布式锁,但获取锁标识与释放锁不是原子操作,会导致集群释放错误锁(阻塞问题,使线程1释放线程2的锁),因此需要引入lua脚本 **说明使用lua的原因**:首先声明该锁为固定前缀 + 用户id,value值为工具类生成的UUID + 当前线程ID,但因为集群模式下,在线程1判断锁标识(只用前缀 + 用户id),准备删除时阻塞了,然后锁TTL到期释放了,用户重复下单导致线程2开始介入,然后线程1复苏把线程2的锁释放可能导致多单且线程安全问题,因此需要将判断标识与删除锁一气呵成 **问题解决**: - **用户id是字符串对象,toString方法是通过new字符串,导致锁对象会改变**,需要使用intern()方法,intern()方法会优先去字符串常量池里查找与目标字符串值相同的引用返回(只要字符串一样能保证返回的结果一样)。 - 事务是在函数结束之后由Spring提交的,因此锁在Spring提交之前在函数尾就释放了,高并发会出问题,因此**将判断与实际sql隔离,设立两个方法,判断方法中对用户id加锁,但因为在同类内部调用事务注解的方法会导致事务失效,因此需要自己获取当前类的代理类然后调用事务方法(记得引入注解@EnableAspectJAutoProxy(exposeProxy = true)),在启动项添加响应注解;实际sql中实现乐观锁代码即可** - 集群模式下, 每个JVM锁住的同一用户ID是不同的,因此会导致一人多单,需要使用**分布式锁**,但分布式锁无法一定保证获取锁与释放锁是原子性操作(可能是GC等其他不可抗力),本项目中使用**Redis支持的Lua脚本**来保证原子性 #### 二、Redisson 引入redission依赖,之后配置RedissonConfig,之后引入bean,通过getlock获取锁对象,在finally中释放锁即可 ```xml org.redisson redisson 3.13.6 ``` ```java package com.hmdp.config; import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient(){ // 配置 Config config = new Config(); config.useSingleServer().setAddress("redis://192.168.237.100:6379").setPassword("063915"); // 创建redissonClient对象 return Redisson.create(config); } } ``` ```java @Resource private RedissonClient redissonClient; private void handleVoucherOrder(VoucherOrder voucherOrder) { Long userId = voucherOrder.getUserId(); // 创建锁对象 RLock lock = redissonClient.getLock("lock:order:" + userId); // 获取锁 boolean isLock = lock.tryLock(); // 判断是否获取锁成功 if(!isLock){ log.error("不允许重复下单"); return; } try{ proxy.createVoucherOrder(voucherOrder); } finally { lock.unlock(); } } ``` ### 8、Redisson获取锁的过程? redisson的锁实际上是一个hash结构,key为本机Redisson的id + 当前线程id,value是重入锁的次数 ![img](https://i-blog.csdnimg.cn/blog_migrate/2cb3dbb80dae20eccdf5bf1e14e091e0.png) 先尝试获取锁,判断TTL是否为null(判断之前是否有线程获取到该锁),不为null通过redis订阅当前该锁的消息,**没指定锁过期时间(leaseTime)**会通过看门狗WatchDog机制(默认锁为30秒,每10秒会检查一次,还在使用锁则重置为30秒,直到其他线程释放该锁发通知,取消WatchDog机制 —— **拓展**:当服务端挂了,这看门狗也不会进行就释放了,不会死锁) ![img](D:\Learning\Java\笔记图片\redis\5df598c77ba27378858042f35d5d0296.png) ### 9、集群模式主从同步会有什么问题? 可能会出现redis主节点挂掉,而此时redis上的分布式锁还没来得及同步到从节点上,进而导致锁失效 **解决方法**:联锁:使用分布式锁 Redissson的multiLock,现在在所有主节点中都存放一份锁,要求一个线程必须从所有主节点中获取锁,才算真正获取锁。现在在所有主节点中都存放一份锁,要求一个线程必须从所有主节点中获取锁,才算真正获取锁。 ![img](D:\Learning\Java\笔记图片\redis\08479743977ed82e33c0994b4051efc6.png) ### 10、Lua脚本的使用? 首先配置Lua脚本,在静态代码块中进行配置 ```java private static final DefaultRedisScript SECKILL_SCRIPT; static { SECKILL_SCRIPT = new DefaultRedisScript<>(); SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); SECKILL_SCRIPT.setResultType(Long.class); } ``` 其次通过StringRedisTemplate的execute来调用 ```java Long result = stringRedisTemplate.execute( SECKILL_SCRIPT,// 预加载的脚本 Collections.emptyList(), // 键参数 voucherId.toString(), // 值参数1 UserHolder.getUser().getId().toString() // 值参数2 ); ``` ### 11、为什么使用RabbitMQ代替Redis的Stream消息队列 **消息持久化**:RabbitMQ可以通过持久化配置直接将消息写入磁盘中,还可以将消息大小大于内存的瞬态消息写入磁盘中;Stream的消息通过AOF写盘以及主从复制都是**异步请求**,都存在数据丢失的可能 **空间受限**:RabbitMQ是默认将消息存在内存中,如果有开启消息持久化则刚到达队列时就写入磁盘中(也可以在内存写入备份,加快速度,内存吃紧时删除),也可以在内存不够用时,将后续消息写入磁盘中,已节省空间 以下三篇写的Rabbit的特性和与Stream的对比,总的**Stream更适合一些实时性的数据处理/监控等,Rabbit更适合做稍复杂的微服务异步处理** [【RabbitMQ 实战】10 消息持久化和存储原理_mq持久化-CSDN博客](https://blog.csdn.net/suyuaidan/article/details/133735978) [Redis 消息队列的终极解决方案 Stream - 万明珠 - 博客园](https://www.cnblogs.com/wan-ming-zhu/p/18080644) [RabbitMQ 与 Redis 对比 - 发布/订阅消息收发系统之间的区别 - AWS](https://aws.amazon.com/cn/compare/the-difference-between-rabbitmq-and-redis/) ## 差异摘要:RabbitMQ 与Redis 发布/订阅 | | **RabbitMQ** | **Redis** | | ---------- | ---------------------------------------- | -------------------------------------------------------- | | 消息传输 | 经过保证的消息送达。支持复杂逻辑。 | 不保证消息送达。需要来自订阅者的活跃连接。 | | 消息大小 | 消息大小限制为 128MB。可以处理大型消息。 | 没有消息限制,但在处理大型消息(大于 1MB)时性能会降低。 | | 消息持久性 | 支持持久和瞬态消息。将持久消息写入磁盘。 | 默认情况下不支持持久消息。 | | 消息加密 | 支持 SSL 加密。 | 在 Redis 6.0 及更高版本中提供 SSL 加密。 | | 速度 | 每秒多达数万条消息。 | 每秒多达数百万条消息。 | | 可用性 | 在集群中创建多个点对点节点。 | 在集群中使用领导-从属模型。 | ### 12、如何实现的点赞功能及点赞排行? 实现用户第一次点赞高亮显示已点赞,第二次点赞则取消点赞: **给Blog添加isLike变量(没有存储在数据库中,因此每个线程用户的Blog的isLike是不同的)**在queryBlogById每个用户查询Blog时(无论普通还是热点的博客)都会在Redis中设置的每个blog的Sorter set中查询该用户是否点过赞,没点过则在Blog Zset中添加BlogId :UserId :currentTimeMillis,点过赞则删除用户Id key即可 ```java stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis()); ``` 点赞接口,从Sorter set中的UserID获取分数,有分则从set中删除用户id,没分则存用户id和当前时间ms数为分 **Sorter Set**数据结构:存储key - value score 的形式,每个key都有一个value和score值,按点赞时间顺序排序,先截取后点赞的五名用户,然后升序排显示 获取前五名:range方法 ```java Set set = stringRedisTemplate.opsForZSet().range(key, 0, 4); ``` 按顺序查出用户信息 ```java List ids = set.stream().map(Long::valueOf).collect(Collectors.toList()); String join = StrUtil.join(",", ids); List userDTOS = userService.query() .in("id", ids).last("ORDER BY FIELD(id," + join + ")").list() .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); ``` ### 13、如何设计的共同关注功能? 将用户关注其他用户,会**使用set将其关注的用户id存储到redis中**,当用户查询与其他用户公共好友时,后端接口去redis中查询当前用户id与目标用户id的set交集(**Sinter方法**,通过将set .stream流转为map在使用方法封装到list中) 之后返回UserDTO的list即可 ### 14、简单说说你怎么实现Feed流关注推送? 采取推拉模式,对于用户的关注量进行区分,对于粉丝多的大V用户采取推拉模式,对于活跃的用户采用推模式,对于不活跃的用户采用拉模式(**拉模式**:即有用户需要查看收件箱时,才向收件箱中推送),对于普通用户来说采用**推模式**,当发布内容即刻将博客id推送至所有关注该用户的其他用户 因为需要读取最新的消息,读取时会可能会改变下标,采用Sorted Set存储,在score中查询并记录上次分页查的最小时间戳,下次查的时候将最小的作为最大的查询。