一、短信登录
1. 项目架构
(1)架构介绍
客户端经过nginx反向代理访问Tomcat服务器集群,Tomcat服务器与Redis集群和Mysql集群完成数据交互,Redis集群和Mysql集群存储数据。
(2)Nginx
- nginx是一款轻量级的HTTP和反向代理web服务器。通过将前端发送的动态请求由nginx转发到后端服务器,启动nginx服务后,通过端口号80进行访问。
- nginx反向代理的好处:①提高访问速度;②进行负载均衡(本项目通过使用不同端口启动后端服务,实现服务器集群,通过nginx反向代理实现负载均衡);③保证后端服务安全。
(3)Tomcat服务器
- Tomcat服务器是一个轻量化的应用服务器
-
Tomcat的功能组件: ①Connector:负责接收和响应请求。它是Tomcat与外界的交通枢纽,监听【Tomcat配置文件中指定的】端口(默认为8080)接收外界请求,并将请求处理后传递给容器做业务处理,最后将容器处理后的结果响应给外界。②Container:负责对内处理业务逻辑。其内部由 Engine、Host、Context和Wrapper 四个容器组成,用于管理和调用 Servlet 相关逻辑。③Service:对外提供的 Web 服务。主要包含 Connector 和 Container 两个核心组件,以及其他功能组件。Tomcat 可以管理多个 Service,且各 Service 之间相互独立。
(4)Nginx和Tomcat服务器的区别
核心:①Tomcat服务器可以访问动态资源 (静态资源和动态资源的简单区别在于:静态资源是在不修改代码的前提下任何用户任何时间访问的内容是一样的,html,css,js等,动态资源需要做数据和逻辑处理)②Nginx可以做反向代理。
2. 基于Redis的Session登录实现
(1)相关概念
- Session&Cookie
- Token:Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。Token则需要客户端手动携带,用于识别用户。
- Socket:套接字通信机制,这种机制使客户/服务器之间基于网络协议的进程通信既可以在本地单机上进行,也可以跨网络进行。
(2)登录验证功能实现
实现流程:①用户在发起请求后,携带cookie,cookie中包含JSESSIONID,用于识别session,进而从Session中获取用户,判断用户是否存在判断是否登录。
②由于有一系列的Controller都需要登录验证操作,因此把该部分放到拦截器部分做。
实现步骤:
①创建拦截器类,重写preHandle和afterCompletion函数
②在preHandle验证该用户是否存在
(3)基于Redis实现共享Session登录
问题:在服务器集群下,Session无法实现共享,影响性能。
功能一、发送验证码
①验证手机号合理性②随机生成6位验证码③以String方式保存在Redis中,并设置过期时间
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号(正则方式验证)
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到 session
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 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)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
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);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3.存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);
}
核心代码分析:①通过UUID随机生成令牌。②将UserDTO转换为Map结构,属性名称作为key,具体的值作为value进行(并将他们转换位string形式)③token作为key,userMap作为Hash结构的值保存在Redis中。即通过Redis的HashMap保存UserDTO对象。④设置token有效期⑤返回token值,用于用户识别。
功能三、校验登录状态--拦截器实现
①创建拦截器1——检验登录并更新token有效期
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
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
核心代码分析:①在Requset请求头中提取token②根据token,查询Redis中是否存在该用户③查询后将结果转换位UserDTO④将UsesDTO存入ThreadLocal(线程存储空间)⑤更新token有效期。
②创建拦截器2——实际检验用户是否登录,是否拦截
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;
}
// 有用户,则放行
return true;
}
}
核心代码分析:如果拦截器1一切顺利,会存入用户到ThreadLocal,如果未保存,表明用户未登录,拦截该请求。
③拦截器配置(WebMvcConfigurer)
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
核心代码分析:登录拦截器,配置所需拦截路径,配置拦截器执行顺序。
异常记录:创建拦截器类实现HandleInterceptor接口,重写方法时必须写@Override注解,否则拦截器不执行。
二、商户查询缓存
1. 缓存
2. 基于Redis缓存的商铺信息查询
(1)实现流程
(2)代码实现
@override
public Result queryById (Long id) {
string key = "cache : shop:" + id;
//1.从redis查询商铺缓存
string shopJson = stringRedisTemplate.opsForValue() .get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean( shopJson,Shop.class);
return Result.ok(shop);
}
//4.不存在,根据id查询数据库
shop shop = getById (id);
//5.不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
// 7.返回
return Result.ok(shop);
}
核心代码分析:①以id为key,查询Redis是否有该商铺信息②有:通过JSONUtil.toBean转换为Shop对象返回直接返回 ③没有查询数据库,获得Shop对象。④以shopId作为key,Shop对象转换成Json的字符串形式作为value存入Redis。⑤返回
3. 缓存更新策略——数据一致性问题
(1)缓存更新策略
①内存淘汰:Redis本身的内存淘汰机制②超时剔除:ttl ③主动更新:自己手写的数据库更新策略
(2)主动更新的问题
①读数据时,缓存未命中,查询数据库写入缓存,通过超时剔除作为缓存更新策略的兜底方案
②写数据时的问题:
- 删除缓存中的数据还是更新数据?删除数据。删除数据可以在更新数据库时让缓存失效,等查询时再进行缓存更新,避免一些无效的读写操作。
- 先操作数据库,再删除缓存还是先删除缓存,再操作数据库?先操作数据库,再删除缓存,因为对于后者:操作数据库的时间较长,在删除缓存,更新数据库未完成时,另一个线程来查询缓存发现数据未命中并查询数据库写入旧数据的可能性更高。前者:若某个数据恰好失效,删除缓存,这时候查询数据库写入的是旧数据,此时再更新数据库,再删除缓存,出现数据一致性问题。可能性小。
- 怎么保证操作原子性:事务或者分布式中一致性的解决方案。
(3)店铺信息更新的代码实现(确保数据一致性)
@Override
@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();
}
4.缓存穿透、缓存雪崩、缓存击穿
(1)问题描述
缓存穿透:当访问缓存和数据库中都不存在的数据时,Redis缓存未命中后会访问数据库进行查找,但当大量这样的请求同时访问时,会导致数据库崩溃,存在数据库安全的问题。(缓存空对象、布隆过滤器)
缓存雪崩:同一时间段大量的缓存key同时失效或者Redis服务宕机,导致大量的请求到达数据库,给数据库带来很大的压力。(有效期+随机值)
缓存击穿:对于热点key,被高并发访问并且重建业务叫复杂的key突然失效,无数的请求访问会在瞬间到达数据库,给数据库带来巨大的压力。(互斥锁、逻辑有效期)
(2)解决方案
缓存穿透
- 布隆过滤器:在Redis缓存之前添加布隆过滤器(一种通过Hash计算、比特位存储来判断数据是否存在的存储工具)数据预热时,预热布隆过滤器。当数据来时,首先在布隆过滤器中查找该数据是否存在,不存在,直接返回。内存占用少,存在误判误差。代码实现具体见博客:java实现布隆过滤器(手写和Guava库提供的)_布隆过滤器 java-CSDN博客
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://120.48.17.2");
config.useSingleServer().setPassword("123456");
//构造Redisson
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("随便起个名");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03);
//将号码10086插入到布隆过滤器中
bloomFilter.add("10086");
//判断下面号码是否在布隆过滤器中
//输出false
System.out.println(bloomFilter.contains("123456"));
//输出true
System.out.println(bloomFilter.contains("10086"));
}
- 缓存空对象:在Redis缓存未命中查询数据库时,若返回空值,则缓存空对象“”。在下次访问Redis缓存请求时,可以在缓存中查询到当前空值,并返回。操作简单,需要额外的key,占用内存。
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);
return r;
}
- 其它需要注意的点:①增强id的复杂性,避免被猜测id规律(时间戳)②做好数据的基础格式校验③加强用户权限校验④做好热点参数的限流。
缓存雪崩
- 给不同的Key的TTL添加随机值(Random),解决大量key同时过期的情况
- 利用Redis集群提高服务的可用性(解决Redis宕机)
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
- 互斥锁:添加互斥锁,在查询缓存未命中时,获取互斥锁查询数据库重建缓存数据;其它线程阻塞(自旋),避免多个线程同时访问数据库出现的问题。
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
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);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
核心代码分析:①Redis查询数据是否命中,命中返回。②不存在,获取锁。③.1未成功,休眠后继续尝试获取锁。③.2 成功,进行数据库和缓存重建操作(顺便缓存空值解决缓存穿透),写入Redis缓存,并返回数据。④释放锁
- 逻辑过期时间:①添加逻辑过期时间,实质上就是保存了一个数值,用RedisData类封装,转换为str后使用Redis的String保存②添加互斥锁,在发现逻辑过期时,获取互斥锁。③获取锁失败,先返回旧的数据④成功后开启一个独立的线程实现查询数据库和缓存重建的过程,需要添加逻辑过期时间。
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
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 r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}