Redis实践-共享Session
1.项目背景
经典的黑马点评项目背景,其中包含大量的Redis应用场景,我们后续Redis的实践也是以此为项目背景的,废话不多说,先来看看用到了那些表吧:
项目还是采用经典的单体前后端分离项目,不过单体项目集群部署,因此需要考虑很多分布式的场景:
2.基于Session实现登录
登录功能需要输入手机号和验证码(或密码),传统的基于Session实现的登录功能一般包括三个部分:发送短信验证码、短信验证码登录或注册、校验登录状态。
发送短信验证码的请求路径和参数信息如下图,代码实现的重点就是设置Session的属性如session.setAttribute("code", "470332")
,Session的实现本质还是基于Cookie的,服务器内部维护了不同会话的Session信息,当用户请求第一次使用Session时服务器会创建一个新的Session对象,并生成一个唯一的会话标识符如JSESSIONID
,然后通过响应头的Set-Cookie
字段发送给用户,保存在Cookie中。服务器利用该会话ID来识别来自同一用户的后续请求,并访问与该会话ID关联的Session数据。
短信验证码登录的请求路径和参数如下图所示,应用服务器会比较用户传入的验证码与Session中保存的验证码(通过session.getAttribute("code")
)是否一致,通过校验后查询数据库用户是否存在,如果不存在证明是新用户,需要注册一条新用户纪录,最终将用户信息保存在Session中。
登录状态验证功能是项目中大部分功能的普通前置条件,因此放入拦截器
中实现,并通过MVC配置类
定义拦截规则
和过滤规则
,为了方便使用,我们还将Session中保存的用户信息转移到统一的ThreadLocal
变量,并且请求线程使用完后立即销毁
防止内存泄漏。
3.集群的Session共享问题
Tomcat集群中的Session共享是一个重要的问题,尤其是在负载均衡环境下,确保用户在多个服务器实例之间无缝切换而保持会话状态是至关重要的。以下是几种常见的解决Tomcat集群中Session共享问题的方法:
3.1. 粘性会话(Sticky Sessions)
- 原理:用户的所有请求都被路由到他们最初用于创建会话的同一个 Tomcat 实例。
- 实现:通过负载均衡器配置,如 Apache HTTPD 或 Nginx。
- 优点:实现简单,无需在 Tomcat 实例之间同步会话数据。
- 缺点:如果一个节点失败,用户会话信息可能丢失。
3.2. 会话复制(Session Replication)
- 原理:会话数据在所有节点间复制和同步。
- 实现:使用 Tomcat 提供的集群功能,如 DeltaManager(适用于小型集群)或 BackupManager(适用于较大集群)。
- 优点:即使某个节点失败,用户会话信息仍然可以从其他节点获得。
- 缺点:随着集群规模的增大,数据复制的开销也增加。
3.3. 集中式会话存储
- 原理:所有会话数据存储在集中的存储系统中,如 Redis、Memcached 或数据库。
- 实现:使用 Spring Session 或自定义的存储解决方案。
- 优点:提高了系统的可伸缩性和可靠性,不依赖于单个 Tomcat 实例。
- 缺点:可能需要额外的存储系统管理和配置。
3.4. 客户端存储
- 原理:将会话数据存储在客户端,例如使用 Cookie。
- 实现:在应用程序中实现会话数据的序列化和反序列化。
- 优点:减轻了服务器端的存储压力。
- 缺点:受限于客户端存储的大小,且可能存在安全隐患。
4.基于Redis实现共享Session登录
使用Redis实现共享Session登录,其中涉及Session的地方都使用Redis加以改造。其中需要关注Redis的Key名称,建议使用业务名:实体名:ID
的形式,如login:code:13233456638
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| public Result sendCode(String phone) { if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail(SystemConstants.PHONE_NUMBER_FORMAT_MESSAGE); } String code = RandomUtil.randomNumbers(6); stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES); log.info("发生短信验证码:{}", code); return Result.ok(); }
public Result login(LoginFormDTO loginForm) { if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) { return Result.fail(SystemConstants.PHONE_NUMBER_FORMAT_MESSAGE); } String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + loginForm.getPhone()); if (cacheCode == null || !cacheCode.equals(loginForm.getCode())) { return Result.fail(SystemConstants.PHONE_CODE_FAIL_MESSAGE); } User user = query().eq("phone", loginForm.getPhone()).one(); if (user == null) { user = new User(); user.setPhone(loginForm.getPhone()); user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + loginForm.getPhone()); save(user); } String token = UUID.randomUUID().toString(true); UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())); stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY + token, map); stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); return Result.ok(token); }
|
校验登录状态的逻辑没有任何变化,仅仅改动为Redis实现,不过有一点需要注意:之前的登录拦截器只会拦截部分接口,如果用户登录后一直不访问这些接口例如查看商铺列表,由于Redis的自动过期机制用户会突然下线,因此我们需要一个用户保活的办法。
拦截器链上的第一个就是RefreshTokenInterceptor
,用于刷新用户token的有效期,对所有请求路径开放。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| 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 { String token = request.getHeader("authorization"); if (StrUtil.isBlank(token)) { return true; } Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token); if (entries == null || entries.isEmpty()) { return true; } UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false); UserHolder.saveUser(userDTO); stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); return true; }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
|
拦截器链上的第二个就是UserInterceptor
,用于拦截不合法的请求,作用在需要登录的路径如查看个人信息等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| public class UserInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (UserHolder.getUser() == null) { response.setStatus(403); return false; } return true; } }
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(); } }
|
除了拦截器的代码,我们还需要编写拦截器配置类的策略,如每个拦截器的拦截路径、过滤路径、优先级等,这是通过配置类完成的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @Configuration public class MvcConfig implements WebMvcConfigurer { @Autowired private StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new UserInterceptor()).excludePathPatterns( "/user/code", "/user/login", "/blog/hot", "/shop/**", "/shop-type/**", "/upload/**", "/voucher/**" ).order(1); registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)) .addPathPatterns("/**").order(0); } }
|