面试项目准备:黑马点评项目总结
1. 项目介绍
黑马点评项目是一个前后端分离项目,类似于大众点评,实现了发布查看商家,达人探店,点赞,关注等功能,业务可以帮助商家引流,增加曝光度,也可以为用户提供查看提供附近消费场所,主要。用来配合学习Redis的知识。
基于 Redis + Springboot的点评APP ,实现了短信验证码登录、查找店铺、秒杀优惠券、发表点评、关注推送的完 整业务流程。
1.1 项目使用的技术栈
SpringBoot+Mysql+Lombok+MyBatis-Plus+Hutool+Redis
1.2 项目架构
后端部署在Tomcat上,前端部署在Nginx。
Nginx作用:
1. 反向代理Tomcat服务器,解决多台服务器,session不共享问题,隐藏真实服务地址。
2. 负载均衡降低服务器压力。三种负载均衡方式:轮询法(默认方法)、weight权重模式(加权轮询)、ip_hash
Nginx的静态处理能力很强,但是动态处理能力不足,因此,在企业中常用动静分离技术。
2. 各个功能模块
2.1 登录模块
短信登录功能(基于session)
- 发送验证码
校验手机号、判断格式是否正确、正确生成验证码、发送验证码。 - 校验手机号和验证码
校验手机号、校验验证码、查找用户、如果没有创建用户,保存用户到session。
以上完成的两步把用户信息保存到session中了。然而有许多页面都需要用户信息和校验登录状态。
- 校验登录状态
访问不通的后端控制器,要获取数据之前需要校验登录状态,用拦截器实现最好,减少代码冗余。
拦截器实现,访问之前从session中获取用户,如果用户存在放行,并且把用户保存到ThreadLocal中去,不同的线程互不干扰。访问之后,把ThreadLocal保存的信息删除。
配置拦截器生效,选择要拦截的请求或是排除不拦截的。
基于redis的短信登录
session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同服务器会导致数据丢失问题。(可以用Tomcat间的数据同步解决,但还是会出现数据不一致和占用内存问题)
session代替方案应该满足:
- 数据共享
- 内存存储
- key,value结构
使用redis代替session是完全可以的
问题:是访问不拦截的页面,token不会刷新。而session是访问哪个页面都会刷新。
优化:再加一个拦截器,拦截所有请求并且有token的话就刷新,第二个则判断用户是否在ThreadLocal中存在。这样就不会出现不刷新的现象。
2.2 用户查询缓存模块
什么是缓存?
数据交换的缓冲区(cache),是贮存数据的临时地方,一般读写性能高。
缓存的作用:
1. 降低后端负载。
2. 提高读写效率,降低响应时间。
缓存的成本:
1. 数据一致性成本
2. 代码维护成本
3. 运维成本
添加Redis缓存
根据id查询店铺缓存的流程
主动更新策略
先删除缓存,在操作数据库
- 在线程1 删除缓存后,线程2查询缓存未命中,然后去查询数据库,最后把查询结果写入缓存。但此时,线程1更新数据库的操作还没有完成,线程2查到的是旧的值,写入了缓存。当线程1更新完数据库之后,就会造成数据库和缓存数据不一致问题。
先操作数据库,再删除缓存
- 要想并发问题发生,首先要线程1查询缓存,刚好缓存失效,然后去查询数据库。此时线程2要去更新数据库,然后去删除缓存。如果线程2在线程1的写入缓存之前更新完数据库和删除完缓存,南无就会造成数据不一致问题。但毕竟缓存的操作速度快和线程1查询时缓存刚好失效并且线程2要去更新数据库。这些事情发生的概率极小。
所以选择先操作数据库,再删除缓存。(给数据库操作加锁的话,应该可以解决并发问题)
缓存穿透
- 是指用户要查询的数据在缓存和数据库中都没有,这样缓存永远不会生效,所有的请求都会到达数据库,造成数据库巨大的压力。
常见的解决方案有两种
- 缓存空对象
- 优点:实现简单,维护方便。
- 缺点:内存的消耗、短期的数据不一致。
- 布隆过滤器
- 优点:内存占用少,没有多余的key.
- 缺点:实现复杂、存在误差。
查询店铺的缓存穿透解决(使用缓存空对象方式)
代码如下:
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
//1 从Redis查询商铺信息
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2 判断是否存在,isNotBlank(null," ", "")
if (StrUtil.isNotBlank(shopJson)) {
//3 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if (shopJson != null) { //因为等于null是没有查到缓存,其他的""、" "是缓存的空对象直接返回
//返回错误信息
return null;
}
//4 不存在,根据id查询数据库
Shop shop = getById(id);
//5 不存在,返回错误
if (shop == null) {
//将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); //设置ttl
return null;
}
//6 存在,写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7 返回
return shop;
}
null是指没有这个对象,空值(空字符串)是有这个对象,但是里面的内容为空
缓存穿透的解决方案还有哪些?
- 缓存null值
- 布隆过滤
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数限流
缓存雪崩
缓存雪崩是指在一段时间大量的key过期或者Redis宕机,导致大量的请求打到数据库,给数据库造成巨大的压力。
解决方案
- 给不同可以的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给业务添加降级限流策略
- 给业务添加多级缓存(浏览器缓存、Nginx缓存、Tomcat缓存等)
缓存击穿
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存业务重建复杂的key突然失效了,无数的请求访问会在瞬间给数据库造成巨大的冲击。
常见的解决方案有两种
- 互斥锁
- 逻辑过期
在业务中经常会遇到一致性和可用性的选择。
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题。
代码如下:
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//缓存击穿 逻辑过期
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
//1 从Redis查询商铺信息
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 null;
}
// 5.2已过期,需要缓存重建
//6.缓存重建
//6.1获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//6.2判断获取锁是否成功
if (isLock){
if(!expireTime.isAfter(LocalDateTime.now())) {
//6.3成功开启独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, 20L);
} finally {
unlock(lockKey);
}
});
}
}
//7 返回
return shop;
}
缓存工具的封装
代码如下:
//缓存穿透 缓存空对象
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
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) {
//将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6 存在,写入Redis
this.set(key, r, time, unit);
//7 返回
return r;
}
//缓存击穿,设置逻辑过期时间
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
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);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1未过期,直接返回店铺信息
return null;
}
// 5.2已过期,需要缓存重建
//6.缓存重建
//6.1获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//6.2判断获取锁是否成功
if (isLock){
if(!expireTime.isAfter(LocalDateTime.now())) {
//6.3成功开启独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R r1 = dbFallback.apply(id);
this.setWithLogicalExpire(key, r1, time, unit);
} finally {
unlock(lockKey);
}
});
}
}
//7 返回
return r;
}
使用方式
//一行解决缓存穿透,封装了方法
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, id2 -> getById(id2), CACHE_SHOP_TTL, TimeUnit.MINUTES);
2.3 优惠券秒杀功能
全局唯一ID
代码如下:
public long nextId(String keyPrefix) {
//1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowSecond - BEGIN_TIMESTAMP;
//2. 生成序列号
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); //方便统计年月日
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);//一天下单的量,拼接日期,还有统计效果
//3. 拼接并返回
return timeStamp << COUNT_BITS | count;
}
全局唯一ID生成策略:
- UUID
- Redis自增
- snowflake算法
- 数据库自增
Redis自增ID策略:
- 每天一个key,方便统计订单量
- ID构造是时间戳+计数器
实现优惠劵秒杀的下单功能
下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已结束则无法下单
- 库存是否充足,不足则无法下单
更新库存和查询版本是数据库自带命令,是原子操作,不会有线程安全问题。
如果字段不是库存,需要加版本号,可以通过分段锁提高成功率,例如currentHashMap中的分段锁。
一人一单:同一个优惠劵,一人只能下一单。
分布式锁
在集群模式下,普通的锁还会出现问题,因为不同jvm有不同的锁监视器。
代码如下:
public class SimpleRedisLock implements ILock{
private String name;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIP;
static {
UNLOCK_SCRIP = new DefaultRedisScript<>();
UNLOCK_SCRIP.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIP.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId , timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//调用lua脚本, 判断和释放在一行代码执行,满足原子性。
stringRedisTemplate.execute(
UNLOCK_SCRIP,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
分布式锁基于Redis的极端情况,误删情况
极端情况下依然会出现线程误删,释放业务阻塞,以判断完毕。
获取锁标识并判断要和释放锁是原子操作
Redisson入门
Redis三种消息队列
2.4 好友关注功能
基于Set集合的关注、取关、共同关注、消息推送等功能
实现分页查询
3. 总结
使用 Redis 解决了在集群模式下的 Session共享问题,使用拦截器实现用戶的登录校验和权限刷新
基于Cache Aside模式解决数据库与缓存的一致性问题
使用 Redis 对高频访问的信息进行缓存 ,降低了数据库查询的压力 ,解决了缓存穿透、雪崩、击穿问题使用 Redis + Lua脚 本实现对用戶秒杀资格的预检 ,同时用乐观锁解决秒杀产生的超卖问题
使用Redis分布式锁解决了在集群模式下一人一单的线程安全问题
基于stream结构作为消息队列,实现异步秒杀下单
使用Redis的 ZSet 数据结构实现了点赞排行榜功能,使用Set 集合实现关注、共同关注功能