黑马点评项目总结
Posted wazouu
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了黑马点评项目总结相关的知识,希望对你有一定的参考价值。
黑马点评
一、短信登陆功能
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);
@Override
public 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中。
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session)
return userService.login(loginForm,session);
@Override
public 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拦截器:
@Slf4j
public 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();
在配置类中配置拦截器:
@Configuration
public 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","/shop/**","/shop-type/**","/upload/**"
,"/voucher/**"
).order(1);
//token属性的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
4.补充ThreadLocal相关知识
a.ThreadLocal的数据结构
-
Thread类有一个类型为
ThreadLocal.ThreadLocalMap
的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。 -
ThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocal,value为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。
-
每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。
-
ThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构。
-
我们还要注意Entry, 它的key是ThreadLocal<?> k ,继承自WeakReference, 也就是我们常说的弱引用类型。
b.内存泄露问题
由于ThreadLocal的key是弱引用
,故在gc
时,key会被回收掉,但是value是强引用
没有被回收,所以在我们拦截器的方法里必须手动remove()。
二、redis缓存
1.选择缓存更新策略
项目选择了主动更新策略,相对较好,主动更新又有以下三种方式:
选择在更新数据库的同时更新缓存。
操作缓存和数据库时有三个问题需要考虑:
- 删除缓存还是更新缓存?
更新缓存:每次更新数据库都更新缓存,无效写操作较多
删除缓存:更新数据库时让缓存失效,查询时再更新缓存 - 如何保证缓存与数据库的操作的同时成功或失败?
单体系统,将缓存与数据库操作放在一个事务
分布式系统,利用TCC等分布式事务方案 - 先操作缓存还是先操作数据库?
若先删除缓存,再操作数据库:
请求1先把缓存中的A数据删除,请求2从db中读数据,请求1再把db中的A更新
若先操作数据库,再删除缓存:
请求1从db中读取数据A,请求2随后更新db中的数据(缓存中由于没有数据,所以不需要删除),最后请求1更新缓存。
可以看出两种方法都有各自的问题,但是由于写的时间要远大于读的时间,所以先操作db再删除cache的出现问题的几率非常小。
2.业务逻辑
- 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
- 根据id修改店铺时,先修改数据库,再删除缓存
3.缓存存在的问题
a.缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
- 缓存空对象
优点:实现简单,维护方便
缺点:额外的内存消耗,可能造成短期的不一致
适合命中不高,但可能被频繁更新的数据 - 布隆过滤
优点:内存占用较少,没有多余key
缺点:实现复杂,存在误判可能
适合命中不高,但是更新不频繁的数据
解决方案:
/**
* 缓存穿透方法
* @param id
* @return
*/
public <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);
/**
* 逻辑过期解决缓存击穿
* @param id
* @return
*/
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的缺点:
1.CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
2.不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
3.一人一单功能
要求同一个优惠券,一个用户只能下一单
这样的方式会产生并发安全问题:
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了(每个jvm都有自己的锁监视器,集群模式下各个服务器的锁不共享)。
因此,我们的解决方案就是实现一个共享的锁监视器,即:
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
4.基于redis的分布式锁
a.setnx命令
setnx = SET if Not eXists
-
将 key 的值设为 value ,当且仅当 key 不存在。
-
若给定的 key 已经存在,则 SETNX 不做任何动作
黑马瑞吉外卖项目部分知识总结,对项目中的难点、不熟悉内容、项目亮点以及bug进行记录与分享。以及项目中用到的设计思想进行总结