1. 整体方案设计:为什么登录功能绕不开 Redis
做后端开发这些年,我接手过好几个项目的登录模块,从最早的 Tomcat Session 到后来的 JWT,再到今天要聊的 Redis 方案,可以说每一种都有它的适用场景,但基于 Redis 实现登录功能,是目前分布式场景下最主流的做法,没有之一。
先说一个最典型的痛点:以前单体应用直接用 HttpSession 存用户状态,浏览器 Cookie 里带一个 JSESSIONID 就能识别用户。听起来挺省事,但一旦服务扩容到多台机器,用户第一次请求落到 A 机器上,第二次请求被负载均衡转发到 B 机器,B 机器上没有这个用户的 Session,用户就被迫重新登录。为了解决这个问题,过去常用的是 Session 黏滞(Sticky Session)或者 Session 广播同步,前者把负载均衡的路由策略绑死,做不到真正的高可用;后者在节点多的时候同步开销大得惊人,一个用户登录要把 Session 发给所有节点,机器一多基本就废了。
Redis 这套方案解决的核心问题,就是把用户的登录状态从应用服务器的内存中抽离出来,集中存储到一个所有应用节点都能访问的独立中间件里。这样无论请求落到哪台机器上,只要去 Redis 里查一下这个用户的会话数据,就能确认身份,彻底解开了服务无状态和登录有状态之间的矛盾。同时 Redis 本身是内存级读写,单实例 QPS 动辄十万级别,就算用户量上去了,登录校验的性能开销也基本可以忽略,再加上 Redis 天然支持 key 过期,登录 token 的过期时间直接映射成 Redis 的 TTL,系统主动帮你把会话清理掉,省去一堆定时任务。
这个方案适合谁来参考?我觉得只要你做的是 Web 后端,或者哪怕只是自己写点项目练手,都值得花时间把 Redis 登录这套搞透。它不复杂,但里面涉及的 key 设计、序列化选择、过期策略、并发控制这些问题,几乎涵盖了 Redis 在业务场景中 80% 的常用姿势。弄懂一个登录功能,你对 Redis 的理解会从"会用命令"上升到"能设计合理方案"的层面。
2. 技术选型拆解:Redis 数据类型、序列化与 Key 设计的底层逻辑
2.1 为什么存登录态选 String 而不是 Hash
在聊 Redis 存登录态之前,得先搞清楚 Redis 有哪些数据类型,因为不同数据结构和业务场景的匹配度完全不一样。Redis 最基础的是 String,然后是 Hash、List、Set、ZSet,另外还有 Bitmap、HyperLogLog、Geo 这些扩展类型。登录功能里用到最多的就是 String 和 Hash 这两种。
我见过很多新手一上来就用 Hash 存用户信息,字段是 username、userId、avatar 一长串塞进去,觉得这样结构清晰。但登录态的存储其实和普通的用户资料缓存不是一个场景。登录态的访问特征是"一次性读取整份数据",我要校验这个 token 是否有效,只需要确认它对应的数据存在,并且取出 userId 就够了。如果按字段分别读 Hash 的话,虽然也可以,但在序列化机制和对象转换上会多绕一层。相比之下,String 存的就是一个完整的 JSON 字符串,set 的时候序列化进去,get 的时候反序列化出来,整个过程清爽利落。
再有,String 类型的操作是最原子、最底层的,Redis 对 String 的读写性能也是所有类型里最好的。登录校验是每次请求都会触发的操作,属于高频路径,用 String 能省一丁点序列化和命令解析的损耗。一次请求省 0.1 毫秒听起来不多,但你的系统一天扛几千万请求的时候,这个差距就体现出来了。
当然 Hash 也不是不能用,如果你的登录态里要频繁单独更新某个字段(比如更新用户的最后活跃时间),Hash 可以做到只更新一个 field,而 String 需要把整个 JSON 取出来反序列化再改再写回去,这种情况下 Hash 更合适。但从大多数业务场景来看,登录态是一次性写入、整体读取,String 就是最优解。
2.2 序列化方案选择:JDK、Jackson 还是 Fastjson
Redis 存的只能是字节或字符串,Java 对象的存储必然要经过序列化。这里有个特别值得说的坑,我见过不少项目因为序列化方案没选对,后面排查问题的时候欲哭无泪。
Spring Data Redis 默认提供的序列化器是 JdkSerializationRedisSerializer,你如果不去改它,直接往 RedisTemplate 里 set 一个对象,产生的是一串带类型信息的二进制数据。数据能存也能读,项目也能跑,但是你在 Redis Desktop Manager 里看到的是一堆类似\xAC\xED\x00\x05t\x00...的乱码,想手动查一条数据看看对不对,根本没法看。再有一点,JDK 序列化要求对象实现Serializable接口,而且每次改对象字段,序列化版本号变了可能就反序列化失败。这种方案在生产环境和调试体验上都很差,个人强烈建议换掉。
比较靠谱的搭配是 key 用 StringRedisSerializer,value 用 GenericJackson2JsonRedisSerializer。key 用 String 序列化是为了保证可读性,红框搜索的时候能直接看到login:token:xxx这样的 key;value 用 Jackson 序列化则是把对象存成规范可读的 JSON 字符串,调试的时候一眼就能看出内容对不对。这里要注意一点,GenericJackson2JsonRedisSerializer 会在 JSON 里加上@class字段记录类型信息,反序列化的时候能自动还原成对应的对象。如果你用的是不带类型信息的普通 Jackson 配置,读出来默认是 LinkedHashMap,强转成自定义类型会直接报 ClassCastException,这个坑我在下面的问题排查部分还会详细说。
Fastjson 我不是很想推荐,虽然它的性能确实不错,但历史上有过几次安全漏洞,而且社区活跃度不如 Jackson。Spring Boot 默认整合的就是 Jackson,没必要为了这点性能差异引入一个额外依赖。
2.3 Key 的命名规范和过期时间的测算思路
Redis 的 key 是全局平铺的,没有"库表"的概念,所以命名规范特别重要。我见过一个项目把所有 Redis 数据都用一个单词做 key,比如直接存user:1001,结果后来加了验证码功能,key 叫code:xxx,再后来加了商品缓存,key 叫goods:xxx。当时看着挺清晰,但等 key 的数量上万之后,Redis 那个键空间看着跟垃圾场一样,谁也不敢删,谁也不知道哪些 key 在哪个业务里用着。
推荐的命名方式是业务前缀:功能模块:标识,比如登录态的 key 应该是login:token:uuid,验证码应该是login:code:userId,商品缓存应该是product:detail:1001。这样当你用 Redis Desktop Manager 按前缀筛选的时候,同一类业务的 key 都聚在一起,排查问题非常方便。而且通过 key 前缀能很直观地看出业务归属,后面的维护成本低很多。
过期时间这块,TTL 的设计要结合业务需求。如果做完登录功能用户能一直挂着,那 token 的过期时间就应该和 Session 超时时间保持一致,一般是 30 分钟,活跃用户会自动续期;如果是那种安全级别比较高的后台管理系统,可以把过期时间缩短到 15 分钟,配合续期机制来平衡体验和安全性。理论上,Redis 的 TTL 精确度很高,实测下来 key 过期会在时间到之后的极短时间内被清理掉,不会出现明显的误差问题。设置过期时间的时候要注意,TTL 的单位是秒,设置的时候不要把这个搞混了,比如你要 30 分钟,应该写 1800,而不是 30。
我个人的习惯是,登录 token 过期时间设置成 30 分钟,活跃用户每次请求接口的时候,校验通过后顺手把 TTL 重新刷新回 1800 秒,这样用户只要一直操作就不会掉线,但是超过半小时没有任何操作,token 就自动失效了。这个逻辑实现起来就是一行expire(key, 1800)的事,但产品体验和纯固定过期时间相比,差别非常明显。
3. 核心功能实操:基于 Spring Boot 完整实现 Redis 登录
3.1 环境准备和依赖引入
用 Spring Boot 集成 Redis 已经算是后端开发的基操了。第一步当然是确保 Redis 服务本身是活的,如果你用的 Windows 开发环境,直接去官网下载 Redis 的 Windows 版本或者用 WSL 装一个都行;生产环境强烈建议用 Docker 来部署,命令非常简单:
docker run -d --name redis-server -p 6379:6379 redis:7.0 --requirepass 你的密码在 pom.xml 里引入依赖,Spring Boot 2.x 和 3.x 都适用:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>commons-pool2 是 Redis 连接池的依赖,不加的话 Lettuce 也能跑,但是连接是每次新建的,高并发下性能会受影响。这个依赖加上去,在 application.yml 里配置一下连接池参数:
spring: data: redis: host: localhost port: 6379 password: 你的密码 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0Spring Boot 2.x 里的配置前缀是spring.redis,3.x 改成了spring.data.redis,这个注意一下,配置不生效的时候八成就是前缀写错了。
3.2 RedisTemplate 配置:让可读性提升十倍
这一步可以说是全篇的核心实操,配置对了,后面的开发顺风顺水;配置错了,后面查数据看到乱码心态直接炸裂。我给出一个经过多轮调整后的标准配置类:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key 使用 String 序列化 StringRedisSerializer stringSerializer = new StringRedisSerializer(); // value 使用 Jackson 泛型序列化 GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }几个容易忽略的细节:setHashKeySerializer和setHashValueSerializer也要设置,不然后续你一旦用到 Hash 结构,key 或者 value 就会被默认的 JDK 序列化处理,又是一堆乱码。afterPropertiesSet()这行是让配置尽快生效的,有些版本不调用也能工作,但建议加上。
配置完成之后,往 Redis 里写入一条数据,用可视化工具看到的效果类似:
| Key | Value | TTL |
|---|---|---|
login:token:9f8d0c2a-1b3e-4a5c-9d7e-8f1a2b3c4d5e | {"id":1001,"username":"zhangsan","role":"admin","loginTime":1699000000000} | 1799 |
这种效果才算配置到位,key 可读,value 可读,排查问题的时候只需要一眼扫过去就能判断登录态是否写入成功。
3.3 登录接口的完整实现:校验、生成 Token、写入过期键
核心登录逻辑是这样的:用户提交用户名密码,校验通过后生成一个 UUID 作为 token,把用户信息序列化后写入 Redis,设置过期时间,然后把 token 返回给前端。前端后续每次请求在 Header 里带上这个 token,后端校验存在性来确定用户身份。
@Service public class LoginService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private UserMapper userMapper; private static final String LOGIN_TOKEN_PREFIX = "login:token:"; private static final long TOKEN_EXPIRE_TIME = 1800; // 30分钟 public LoginResponse login(LoginRequest request) { // 1. 校验用户名密码(实际项目中密码需要 BCrypt 加密存储,这里简化) User user = userMapper.selectByUsername(request.getUsername()); if (user == null || !user.getPassword().equals(DigestUtils.md5DigestAsHex(request.getPassword().getBytes()))) { throw new BusinessException("用户名或密码错误"); } // 2. 生成一次性 token String token = UUID.randomUUID().toString().replace("-", ""); // 3. 构建登录用户信息对象 LoginUser loginUser = new LoginUser( user.getId(), user.getUsername(), user.getRole(), System.currentTimeMillis() ); // 4. 写入 Redis 并设置过期时间 String key = LOGIN_TOKEN_PREFIX + token; redisTemplate.opsForValue().set(key, loginUser, TOKEN_EXPIRE_TIME, TimeUnit.SECONDS); // 5. 返回 token 给前端 return new LoginResponse(token, TOKEN_EXPIRE_TIME); } public void logout(String token) { String key = LOGIN_TOKEN_PREFIX + token; redisTemplate.delete(key); } public LoginUser getLoginUser(String token) { String key = LOGIN_TOKEN_PREFIX + token; Object value = redisTemplate.opsForValue().get(key); if (value == null) { return null; } // 反序列化时,GenericJackson2JsonRedisSerializer 会自动还原类型 // 这里建议直接强转,如果类型信息丢失的话需要手动 convert return (LoginUser) value; } }写这段代码的时候有几个点需要重点提示:
第一,登录用户的密码绝对不能存进 Redis。登录态的关键作用只是标记"这个用户已认证",你存 userId 和 username 就够了。一旦把密码这种敏感信息缓存到 Redis,即使 Redis 有密码保护,内存 dump 或者日志泄漏造成的风险也是不可接受的。
第二,UUID 生成 token 是我们最常用的方式,但业务要求更高的时候,可以考虑用 JWT 代替。JWT 的优点是服务端校验不需要查 Redis,token 本身携带了用户信息,用密钥验签就行。但 JWT 也有特别明显的缺点,无法主动失效,你没办法让一个已经签发的 JWT 立即过期,除非自己维护一个黑名单,这又变相回到了 Redis 存储。所以登录态这种需要能主动踢人的场景,Redis + 随机 token 是更简单可靠的方案。
第三,getLoginUser里的强转,在配置了 GenericJackson2JsonRedisSerializer 的前提下是没有问题的,因为 JSON 里有@class信息,反序列化出来就是 LoginUser 类型。如果你用了不带类型信息的序列化器,这里会抛 ClassCastException。下面会详细说这个坑。
3.4 登录校验拦截器:每个受保护接口的守门员
有了登录接口,还得有校验登录态的拦截器。定义一个 HandlerInterceptor,对所有需要认证的接口做拦截,从 Header 里取 token,查 Redis,查不到就直接返回 401,查到了就把用户信息塞进 ThreadLocal 里供后续业务代码使用。
@Component public class LoginInterceptor implements HandlerInterceptor { @Autowired private LoginService loginService; private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>(); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } LoginUser loginUser = loginService.getLoginUser(token); if (loginUser == null) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } // 登录成功,顺手刷新过期时间,实现滑动续期 loginService.refreshTokenExpire(token); // 用户信息放入 ThreadLocal,业务层直接从 ThreadLocal 获取 USER_HOLDER.set(loginUser); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 请求结束后必须移除,否则线程池复用会导致用户信息串号 USER_HOLDER.remove(); } public static LoginUser getCurrentUser() { return USER_HOLDER.get(); } }拦截器里的refreshTokenExpire实现就一行:
public void refreshTokenExpire(String token) { String key = LOGIN_TOKEN_PREFIX + token; Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS); if (ttl != null && ttl > 0) { redisTemplate.expire(key, TOKEN_EXPIRE_TIME, TimeUnit.SECONDS); } }注意一点,afterCompletion里的ThreadLocal.remove()千万不能省。Web 容器处理请求用的线程池是复用的,如果不清理 ThreadLocal,下一个请求复用这个线程的时候,getCurrentUser()拿到的是上一个用户的信息,这种串号 bug 极其隐蔽,查起来特别费劲。
3.5 注册登录 WebAuthn 的进阶扩展方向
最近 WebAuthn 相关的热度很高,热词里也有 "webauthn实现自定义登录/注册功能 springboot"。如果你觉得传统账号密码登录太老套,想接生物识别或者硬件安全密钥,可以把 WebAuthn 和 Redis 结合起来用。WebAuthn 是一种无密码认证协议,浏览器通过公钥加密的方式生成一对密钥,服务端保存公钥,用户登录的时候用私钥签名挑战值来验证身份。
Redis 在这个场景下的作用主要有两个:一个是保存注册阶段的挑战值 challenge,设置短 TTL,比如 2 分钟,防止重放攻击;另一个仍然是会话管理,认证通过之后签发一个随机 token 存 Redis,逻辑和账号密码登录完全一样。也就是说,不管你前端认证方式怎么换,后端登录态的存储和校验始终可以统一收敛到 Redis 这一层。
我实际调研过的做法是,用 Spring Security 的 WebAuthn 集成库处理认证协议,拿到认证结果之后组装 LoginUser 对象,剩下的 token 生成、Redis 写入、过期管理直接复用现有的 LoginService。这样既保留了生物识别的高级感,又不破坏整体架构的简洁性。这块内容如果你感兴趣,可以单独深入,这里先埋个伏笔。
4. 常见问题与排查实录:序列化陷阱、缓存穿透与并发控制
4.1 反序列化报 ClassCastException 的根治方法
这是我在群里回答了几百遍的问题,几乎每过一段时间就有人贴一段报错来问:
java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class com.example.LoginUser我前面已经提到了,这个问题的根源是序列化器选择不对。你可能在 RedisTemplate 里用了Jackson2JsonRedisSerializer(注意,不是 Generic 版本),这个序列化器虽然输出的是 JSON,但里面没有类型信息。存进去的时候是 LoginUser,读出来的时候 Jackson 只知道它是一个 Object,具体是什么类型它不知道,默认给你还原成了 LinkedHashMap。你一看代码明明是 LoginUser,结果运行起来偏偏报 LinkedHashMap 转不过去,满脸问号。
解决办法有两个。最省事的是换成GenericJackson2JsonRedisSerializer,它会自动在 JSON 里写入一个@class字段记录类型全路径。存的时候是{"@class":"com.example.LoginUser","id":1001,...},读的时候 Jackson 看到@class就知道该转成 LoginUser,强转自然就成功了。
另一个办法是手动做类型转换:
LoginUser loginUser = objectMapper.convertValue(value, LoginUser.class);这个方案要求你自己维护一个 ObjectMapper,代码量多一点,灵活度也高一些。我的建议是,一般业务项目直接用 Generic 版本就够了,省心最重要。
还有个隐藏细节:如果你用 GenericJackson2JsonRedisSerializer,JSON 里会多一个@class字段,这会增加一点存储空间,但一个登录态对象撑死也就多几十个字节,完全可以忽略。如果实在在意空间,可以在配置 ObjectMapper 时关闭默认类型信息,用 activateDefaultTyping 的方式做更精细的控制,但这是优化阶段的活,别在一开始就背上这个包袱。
4.2 Redis 里看到乱码 key 的补救方案
很多人在开发过程中发现 Redis 里出现了一堆\xac\xed\x00\x05t\x00开头的 key,或者 value 是二进制乱码,异常难看。这是默认的 JdkSerializationRedisSerializer 在搞鬼。如果你刚发现这个问题,而且你的 Redis 里存的数据都是登录态这类可以被主动失效的数据,最简单的补救方法是直接清空该业务前缀下所有 key,然后修改 RedisTemplate 配置后重新启动应用。但如果你的数据里已经有不可再生的业务数据,那只能通过临时写工具类的方式把数据读出来反序列化再重新以 String/JSON 的格式写回去,工作量会大一些。
我个人的建议是,在项目开发初期就把 RedisConfig 的序列化方案定下来,别等数据多了再改。而且还应该顺手配一个单元测试,往 Redis 里写入一条数据,读出来断言类型正确,key 和 value 的可读性一目了然。这种测试写一次能受益一整个项目周期。
4.3 缓存穿透与并发重复登录:Redis 登录场景的防护细节
登录场景里的缓存穿透和普通缓存场景还不一样。我这里说的不是那种恶意攻击的穿透,而是业务上常见的场景:大量用户同时使用同一个账号登录,或者同一个用户短时间内疯狂点击"登录"按钮。如果你不加任何防护,每一次点击都会执行一次密码校验,然后生成一个新的 token 写入 Redis。用户那边可能还没感觉到什么,Redis 里的无效 key 已经堆了一堆。
初级的优化是在前端按钮上加 loading 或者禁用置灰,这个属于体验优化,不解决后端实质问题。后端层面,可以对同一个用户 ID 的登录请求做并发控制。用 Redis 的分布式锁机制,key 设计成login:lock:userId,设置 1-2 秒的过期时间,用setIfAbsent实现:
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 2, TimeUnit.SECONDS); if (Boolean.FALSE.equals(locked)) { throw new BusinessException("登录请求处理中,请勿重复提交"); }这只是最基础的姿势,系统学习的话可以去搜 Redis 分布式锁、Redisson 相关的文章,尤其注意看 Redisson 的看门狗机制和锁的可重入设计,能帮你避免死锁和锁误删的问题。这也解释了为什么 Redis 相关面试题里,登录、缓存、分布式锁总是被串在一起问,因为它们在实际系统中本质就是一个整体。
另一个常见问题是防暴力破解。用户密码连续输错 N 次就锁定一段时间,这个也可以依赖 Redis 轻松实现。设计一个 key:login:fail:username,每次密码错误就increment并设置过期时间,如果计数达到上限就拒绝登录。这里要注意的是 key 的过期时间是在第一次写入时设置的,increment操作不会改变 TTL,如果你需要每次输错都重新计时,需要在 increment 之后手动调用expire。这个细节很多人容易忽略,结果就是锁定窗口比预期短很多。
4.4 缓存雪崩和 Key 集中过期的避坑经验
缓存雪崩说的是大量 key 在同一时间段集中过期,导致请求直接穿透到数据库。登录 token 一般不会直接打数据库,但如果你的登录态 token 是每天凌晨统一时间点批量生成的,TTL 又完全一样,很有可能出现凌晨某个时刻大量用户的登录态同时失效,引发一波重新登录风暴。
避免的方法不复杂,一是过期时间加一个随机抖动,比如基础 1800 秒,实际设置的时候加上RandomUtils.nextInt(0, 300)秒的偏移,这样每个用户的过期时间有前有后,不会整齐划一;二是真出现了集中过期的情况,可以在校验逻辑里做一个短期缓存或者降级策略,避免大量请求同时重建会话。
Redis 的 TTL 机制在实现上并不是"到期立即删除"。Redis 同时有主动删除(惰性删除)和被动删除(定时扫描)两种策略,一个 key 到了过期时间并不会立刻从内存中消失,而是等再次被访问或者后台的循环扫描发现了才删掉。所以,如果你在 key 刚过期的瞬间去读,可能会读到还在内存中但已经逻辑失效的数据吗?不会,Redis 的惰性删除会在 get 的时候判断 TTL 是否已到,到了的话直接返回 nil 并删除 key,所以业务上不会读到已过期的数据。但你要额外关注的是内存占用:大量已经过期但还没有被访问和扫描到的 key 会霸占内存,这也是为什么 Redis 配置里要有 maxmemory 和淘汰策略的原因之一,合理配置才能防止内存爆掉。
4.5 Redis 面试常考题:登录功能背后的原理延伸
把所有核心内容过了一遍之后,你会发现基于 Redis 实现登录功能,其实把 Redis 的几个面试高频考点都覆盖了:Redis 的持久化机制(RDB 和 AOF,键值对在重启后是否还能恢复登录态,这里有个取舍,登录态丢了最多重新登录,所以可以考虑关闭持久化或使用 AOF 的较短刷盘周期),Redis 的过期删除策略(惰性删除加定期删除),Redis 的内存淘汰策略(内存满了之后是淘汰最久未使用的 key 还是随机淘汰),Redis 的线程模型(为什么单线程还能这么快),以及 Redis 的分布式锁实现。把这些原理和登录功能关联起来理解,比死记硬背面试题要牢靠得多。
从数据可靠性角度说,Redis 挂着的时候所有用户登录态瞬间全部失效,应用层面需要有一个兜底方案。轻量级做法是在 Redis 不可用的时候,业务可以降级为直接放行,或者通过数据库短暂查询用户信息来重建会话。稳妥的做法是给 Redis 做高可用部署,比如主从架构加哨兵。现在热词里也有 "docker安装redis主从",如果你们公司对登录服务的可用性要求非常高,直接上主从加哨兵的架构会比单节点稳很多。Redis 主从复制的原理是主节点把写操作追加到内存缓冲区,从节点通过 SYNC 命令同步全量数据,之后增量拉取主节点的命令流。整个过程对应用层无感知,属于一个比较成熟的运维方案。
结尾的个人经验
如果让我总结这套基于 Redis 登录方案里最值钱的几条体会,我首先会强调序列化方案一定要在项目启动的第一天就选对,这是后面所有调试体验的地基;其次,key 命名和过期时间的设计要当成接口文档一样重视,因为 Redis 不像数据库有清晰的表结构,它更像一个巨大的内存字典,命名就是你对抗混乱的最有力武器;最后,登录功能虽然看着简单,但它几乎是每个系统里第一个被攻击者盯上的模块,任何一步都不能抱有侥幸心理。
后来的实际项目里,我又在这个登录方案的基础上扩展了多端登录互踢(同一账号新登录后,旧 token 直接删除)、用户在线状态统计(用 ZSet 记录活跃用户和时间戳),以及基于 Redis 的短信验证码限流。这些都是把登录功能做深做扎实的延伸方向。如果你正打算从零开始搭建或者重构登录模块,这套 Redis 方案应该能帮你少踩不少弯路,希望上面的实操细节和踩坑记录对你有用。