说实话,登录校验这需求,每个做后端的人都会遇到。早期我习惯用Session,项目单体阶段挺顺手,直到有一次线上服务扩容,用户登录状态到处乱飘,排查到半夜才意识到:Session存在单机内存里,多实例部署就是给自己埋雷。后来我们把登录态全部切到Redis,一套方案同时解决了集群会话共享、过期控制、主动踢人这些问题,今天就把这套基于Redis的登录校验实现从头到尾拆给大家。
这套方案适合什么场景?只要你需要在多个服务实例之间共享登录态,或者想支持用户强制下线、设备管理、登录态续期,又不想引入太重的认证框架,那Redis登录校验就是一个性价比很高的选择。它不依赖特定语言框架,思路是通用的,后面用Spring Boot举例,其他语言照着搬逻辑就行。
1. 为什么登录校验要引入Redis
1.1 从Session到Redis:登录状态到底该存哪儿
先说说之前用Session踩过的坑。Session默认存在服务端内存里,用户登录后,Session ID通过Cookie写回浏览器,下次请求带着这个ID来,服务端从内存里把Session对象取出来。单机没问题,但一旦做了负载均衡,同一个用户的请求被分发到不同机器,就麻烦了:A机器存的Session,B机器取不到,用户一会儿登录一会儿掉线。
常规解法有几种:一是Session黏滞,让同一个用户的请求固定打到一台机器,但这样流量倾斜,某台机器挂了用户就全掉线;二是引入Session共享组件,配置复杂;三是把Session序列化到Redis里,通过Spring Session这种组件实现,但这套东西有点重,而且和容器绑定得比较死。我们在实际项目里反复比较后,决定不用Spring Session,完全自己基于Redis实现登录校验,原因很简单:我们需要更精细的控制,比如指定用户踢指定设备、调整续期策略,这些通过原生的Redis数据结构来操作更顺手。
登录校验的本质,其实就是"确认当前请求的用户是谁,并且这个身份还在有效期内"。把这个状态从本地内存搬到Redis之后,任何一台服务实例都能查到同一个登录状态,集群部署的问题自然就消失了。
1.2 登录校验看中的Redis特性
Redis能成为登录校验的首选,不是偶然的,几个特性刚好打在需求点上。
首先是过期时间。登录态必须有过期时间,Redis对每个Key都可以设置TTL,到点自动删除。这个特性天然契合"登录态有效期"这个需求。你可以给用户设置2小时不过期,或者让登录态在活跃时一直续期,闲下来就自动失效,Redis的过期机制让这些都变得很简单。
其次是数据结构丰富。登录校验不只是存一个"谁登录了"的标记,还需要知道这个用户有几个设备在线、哪个token是哪个设备的、踢人的时候要踢谁。Redis的String、Hash、Set配合起来,这些都能轻松表达。
然后是原子操作。比如用户重复登录时,要保证"先判断旧token是否存在,再决定是否剔除"这两个动作不能有并发问题。Redis单线程执行命令,天然避免了竞态条件;做分布式锁防并发刷新,也都有现成方案。
最后是可以集中部署、独立扩展。Redis可以单独部署成集群,缓存服务和应用服务解耦,缓存压力再大也不影响业务数据库。Redis的缓存治理能力在登录这个场景同样适用,后面第4节我会专门讲缓存穿透、序列化这些细节。
2. 设计登录校验方案:数据结构与Token模型
2.1 如何选Redis数据类型:String、Hash、Set各自怎么用
很多新手上来就问:登录状态用String存不就完了?真做项目你会发现,一个String远远不够。我按实际场景列一下:
| 存储内容 | 推荐类型 | Key设计示例 | 说明 |
|---|---|---|---|
| Token → 用户ID | String | login:token:{token} | 校验登录态时最频繁的查询,O(1)取value,天然合适 |
| 用户 → 在线设备集合 | Set | login:user:{userId}:devices | 存储该用户所有在线token,支持踢人、查在线设备 |
| 用户 → 设备详细信息 | Hash | login:user:{userId}:info | 字段用token,值存设备名、登录时间、IP等 |
| 防重复提交/限流 | String | login:loginAttempt:{userId} | 带过期时间的计数器,用于登录失败次数限制 |
这里最核心的是前两个。为什么Token到用户ID用String而不是Hash?因为校验请求时就是输入一个token,要立刻查出用户ID,String的get操作一次网络往返搞定,简单直接。Hash虽然也能存,但结构上多一层,没那个必要。
为什么在线设备集合用Set?因为Set天然去重,每个token只出现一次。更重要的是,它支持"拿出所有登录设备"这个操作,踢人时遍历这个集合,把对应的token key一个个删掉就行。
2.2 Token生成规则与存储Key设计
Token不能随便拿UUID糊弄,要考虑安全性和可追溯性。我们生产环境用的是UUID + 随机数 + 时间戳的组合,再做一次MD5或者SHA256散列,得到一个固定长度的token串。为什么要处理?因为直接用原始UUID,万一日志里打印了,别人拿日志就能伪造登录态;散列以后就算看到token也没法反推原始信息,安全性高一个档次。
Key命名规范也很重要。login:token:{token}这种带模块前缀、冒号分层的风格,在Redis Desktop Manager这类可视化工具里看特别清晰。生产上你还会遇到同一个小项目里既有登录token又有验证码缓存、业务缓存,如果key起得乱七八糟,排查问题能翻半天:到底是哪个服务写的、什么时候写的、过期时间多少,全都靠命名去认。
还有一个容易踩的坑:线上环境不同环境的key要隔离。我们会在key里加上环境标识,比如login:token:prod:{token}、login:token:dev:{token}。如果多个环境共用一套Redis,这个区分能保命。
2.3 校验时机与拦截器设计
登录校验不是所有接口都要做。接口可以分三类:不需要登录的(比如登录接口、注册接口、图片验证码)、需要登录的(用户信息、订单)、需要特定权限的(管理员接口)。我们在项目里用拦截器来实现,按URL规则配置放行和拦截。
拦截器处理流程大概是这样:请求进来,从Header里取token(一般叫Authorization),拿不到就直接返回401;拿到了就去Redis查login:token:{token},查不到说明登录态不存在或已过期,返回401;查到就把用户ID塞到请求上下文里,后面的Controller直接用;顺手把这个token的过期时间重置一下,这就是滑动续期。
整个过程Redis只做两次操作,一次get,一次expire,响应耗时增加可以忽略不计。这个设计的好处是,业务代码里完全不用关心"当前用户是谁"这个事儿,从请求上下文里取就行,代码写起来很干净。
3. 核心功能落地:登录、校验、退出、踢人
3.1 登录接口:写入Redis并返回Token
登录接口的逻辑,第一步肯定是校验用户名密码,这块和Redis无关,略过。关键是密码校验通过之后,Redis这段怎么设计。
我给出一个简化版的实现思路:用户提交用户名和密码,校验通过后生成token,然后以token为key、用户ID为value,写入Redis并设置过期时间;同时把token加入该用户的设备集合;最后把token返回给前端。前端的后续请求都带上这个token,后端就知道是谁了。
下面是一段基于Spring Boot的实现示例:
public String login(String username, String password, LoginDevice device) { // 1. 校验用户名密码(省略) User user = userService.checkLogin(username, password); // 2. 生成散列后的token String rawToken = UUID.randomUUID().toString() + System.currentTimeMillis() + ThreadLocalRandom.current().nextInt(100000, 999999); String token = DigestUtils.md5DigestAsHex(rawToken.getBytes(StandardCharsets.UTF_8)); // 3. 写入登录态,有效期2小时 String tokenKey = "login:token:" + token; stringRedisTemplate.opsForValue().set(tokenKey, String.valueOf(user.getId()), 2, TimeUnit.HOURS); // 4. 记录用户在线设备(Set + Hash,方便后面踢人、查设备) stringRedisTemplate.opsForSet().add("login:user:" + user.getId() + ":devices", token); stringRedisTemplate.opsForHash().put("login:user:" + user.getId() + ":info", token, device.toJson()); return token; }这里有两个容易忽略的点。第一,device参数要从请求里解析,可以通过一个自定义注解在Controller拿到,再把设备信息传进来。第二,登录成功后要不要把旧的登录态踢掉?这就看业务怎么定了。如果产品要求一个账号同时只允许一台设备在线,那就在新token写入前,把旧token全部踢掉;如果允许多端登录,就完全不用管。后面我会讲互踢的玩法。
3.2 登录态校验:拦截器实现
登录校验的核心在拦截器。HandlerInterceptor的preHandle方法里做检查,逻辑很集中。来看代码:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { writeUnauthorized(response); return false; } String userId = stringRedisTemplate.opsForValue().get("login:token:" + token); if (userId == null) { writeUnauthorized(response); return false; } // 用户ID放入ThreadLocal/请求属性,业务代码直接取 request.setAttribute("userId", Long.valueOf(userId)); // 滑动续期:每次操作后再给2小时 stringRedisTemplate.expire("login:token:" + token, 2, TimeUnit.HOURS); return true; }这段逻辑有几个细节值得展开。
第一个,为什么用request.setAttribute而不是直接放ThreadLocal?因为一个请求可能经过过滤器、拦截器、Controller多個环节,通过request传递作用域更清晰,而且Spring MVC每请求都会清理,不容易内存泄漏。团队里如果约定好,用ThreadLocal也行,但一定记得在afterCompletion清理,不然线程池复用会导致用户信息串号,这种bug特别难查。
第二个,续期这里我直接调了expire,其实如果token剩余过期时间已经很长了,每次请求都重置是有点浪费的。优化做法是给每个用户单独记一个"最近活跃时间",活跃时间超过某个阈值才续期。对于大部分中小系统,没这个必要,每次都续期也就多一条Redis命令而已。
写拦截器还有个坑,就是拦截路径要把静态资源和公开接口都放行。登录接口本身如果也被拦了,那你永远登录不上去。我一般用注册拦截器时指定includePatterns,把需要登录的路径都列出来,而不是把所有路径拦下来再一个个排除,后者容易漏,出错时排查也麻烦。
3.3 退出登录与账号互踢
退出登录的逻辑很简单,就是把这个token对应的登录态删除,同时从设备集合里移除。但这里有一个必要性不太明显却很重要的事情:如果只删login:token:{token},不删设备集合,时间一长用户设备集合里就积累一大堆失效token,后面"查在线设备"全是不准的。所以退出时两步都要做。
public void logout(String token, Long userId) { stringRedisTemplate.delete("login:token:" + token); stringRedisTemplate.opsForSet().remove("login:user:" + userId + ":devices", token); stringRedisTemplate.opsForHash().delete("login:user:" + userId + ":info", token); }再来说说互踢。管理员在后台把某个用户踢下线,这个操作要的不是删一个token,而是把这个用户所有设备token全部清掉。实现就是遍历Set,删除所有login:token:{token},然后清空Set和Hash。
public void forceLogout(Long userId) { Set<String> tokens = stringRedisTemplate.opsForSet().members("login:user:" + userId + ":devices"); if (tokens != null) { List<String> keys = tokens.stream() .map(token -> "login:token:" + token) .collect(Collectors.toList()); stringRedisTemplate.delete(keys); } stringRedisTemplate.delete("login:user:" + userId + ":devices"); stringRedisTemplate.delete("login:user:" + userId + ":info"); }这里用delete传集合的方式批量删key,比循环单个delete少很多网络往返。数据量大时性能差距很明显。
另一个互踢场景是"新登录踢旧登录"。做法是登录时先调一次forceLogout(userId),再写入新token。但这个顺序要反过来才安全:如果先踢再写,万一写新token失败,用户就一个登录态都没有了。正确做法是先写新token,再把旧的清掉,但要保证新token不在旧集合里被误删。更稳的写法是:取旧设备集合的token,逐个判断是不是当前新token,只删其他的。
public String loginAndKickOld(String username, String password, LoginDevice device) { String token = doLogin(username, password, device); // 找出除了当前token以外的旧token并删除 Set<String> tokens = stringRedisTemplate.opsForSet().members("login:user:" + userId + ":devices"); List<String> oldKeys = tokens.stream() .filter(t -> !token.equals(t)) .map(t -> "login:token:" + t) .collect(Collectors.toList()); if (!oldKeys.isEmpty()) { stringRedisTemplate.delete(oldKeys); stringRedisTemplate.opsForSet().remove("login:user:" + userId + ":devices", oldKeys.toArray(new String[0])); } return token; }这个设计充分考虑到了用户同时用手机App和电脑网页登录的常见场景:管理员可以单独把电脑网页踢掉,手机App不受影响;用户自己重新登录,老设备自动失效。
3.4 续期方案:滑动过期怎么实现
登录态过期策略直接决定用户体验。固定过期时间有一个让人吐槽的点:用户用着用着突然要重新登录,哪怕他一直在操作,时间一到就断了。滑动续期就是解决这个问题的:每次请求都重新设置过期时间"2小时无操作才掉线"。
实现方式上面拦截器里已经给过了,就是每次请求调expire重置TTL。这个方案在实际场景里效果很好,用户只要保持活跃,登录态就一直有效;真正离开2小时,登录态自动失效,安全性也有保障。
当然滑动续期也有个隐患:如果一个token被偷了,只要小偷持续伪造请求,这个token理论上可以永久续期。为了兼顾安全,可以考虑限制最大续期时间,比如最多续7天,之后必须重新登录。实现方式是:token value不只存userId,可以存一个"首次登录时间"或用Hash存更多字段,续期时检查这个时间是否超过7天。不过对多数内部系统来说,滑动续期做到2小时这档就够用了,不用过度设计。
4. 生产环境必须考虑的缓存治理与安全细节
4.1 缓存穿透、击穿、雪崩在登录场景的表现
很多人在Redis缓存治理上有个误区,认为只有高并发查询数据库的场景才有穿透、击穿、雪崩。登录校验同样会遇到,只是表现不一样。
缓存穿透,指查询一个不存在的key,每次都打到数据库。登录场景里,攻击者拿着随机生成的token批量请求,Redis查不到,你的代码如果设计成"查不到就放行去数据库查用户",那数据库就会被打爆。所以在拦截器里,Redis查不到token直接返回401,绝不再去数据库兜底查一次。这就是对穿透最简单的防御。如果数据库非要兜底查,那就用布隆过滤器或者缓存空值,登录场景一般不推荐这么做,因为token不存在就应该是401。
缓存击穿,指一个热点key失效瞬间,大量请求同时打到数据库。登录场景里,"用户登录态是否有效"这个查询没有热点key每个key都不同,所以击穿风险不大。真正有风险的是另一个地方:每个用户的登录失败次数限制,如果有人恶意跑密码,同一个userId的计数key会反复被读写,一旦这个key失效,计数归零,等于给了攻击者无限试错次数。解决方式是给计数key设置较长的过期时间,用分布式锁保证多实例下计数不丢失。
缓存雪崩,指大量key同时失效。如果你的所有用户登录token都统一设置2小时过期,而且所有用户都在同一时间段登录,那高峰期会有一大批token同时到期,Redis的压力倒还好,但用户体验就是"集体掉线"。解决方式是在过期时间上加上随机偏移,比如2小时±10分钟,错峰失效。
4.2 序列化方案:避免乱码和类型转换报错
用Spring Data Redis操作Redis,序列化方案是个大坑。默认的JdkSerializationRedisSerializer会把对象序列化成一堆二进制乱码,在Redis Desktop Manager里看全是\xAC\xED...,根本没法排查问题。而且JDK序列化后的数据很长,浪费内存。
我们的建议是:key用StringRedisSerializer,value根据使用场景来定。如果value只是userId这种字符串,直接用StringRedisTemplate就行,默认就是String序列化,完全不用操心。如果value需要存对象,比如设备信息,就用GenericJackson2JsonRedisSerializer配合一个带类型的ObjectMapper,这样存进去的是JSON字符串,可读性好,而且能保留类型信息,反序列化不会报ClassCastException。
这里有一个细节:使用JSON序列化后,value字段在命令行里看就是普通JSON,redis-cli get login:token:xxx也能正常显示,排查登录问题的时候直接能看懂。如果你用了JDK默认序列化,出了问题想用命令行看一眼都费劲。
另一个细节是:token作为key的时候,如果用的是模板方法生成key,前后端要对齐,不能这边加了前缀那边没加。我们在项目里统一封装了一个LoginKeyBuilder,所有key都通过它生成,杜绝散落各处的硬编码。
4.3 分布式锁在登录校验里的用处
登录接口在特定场景下需要分布式锁。最典型的是防止同一用户并发重复登录:假设用户快速点了两次登录按钮,两个请求同时进来,都校验通过,都写Redis,最后结果是两个登录态同时存在。如果产品要求"单端登录",这就会出现异常状态。
用分布式锁可以这么处理:以userId为锁的key,在登录逻辑外面加锁,保证一个用户的登录操作串行执行。Spring Boot里用RedisTemplate实现锁的原生代码网上很多,但要注意锁必须设置过期时间,防止死锁。现实中我更喜欢用Redisson的RLock,它封装好了看门狗续期机制,简单可靠。
RLock lock = redissonClient.getLock("login:lock:" + userId); boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!acquired) { throw new BusinessException("操作太频繁,请稍后重试"); } try { // 登录逻辑 } finally { lock.unlock(); }分布式锁不是每个登录校验都需要。如果是多端登录设计,并发登录本身不会冲突,完全可以不加锁;如果是单端登录,加上能避免"旧token被新token覆盖时,另一个并发请求把新token误删"这类问题。我建议大家评估业务语义后决定,不要无脑加锁。
4.4 连接超时、连接池与监控
Redis连不上是所有登录功能最大的风险点。如果Redis挂了,整个站点所有用户都无法登录、无法校验登录态,这比数据库挂了的后果还严重。所以生产环境Redis的可用性是第一位的,至少要做主从加上哨兵或者集群模式。从热词里就能看到很多人搜"docker安装redis主从""k8s redis集群",就是因为单点Redis不靠谱。
客户端配置上,Lettuce连接池参数要合理。之前遇到过一个问题:线上高峰期接口偶发变慢,日志里出现RedisCommandTimeoutException: Command timed out。排查后发现两个原因:一是连接池最大连接数设得太小,高峰拿不到连接,请求全在排队;二是部分慢查询把连接占住,后续命令跟着等待。后来把连接池maxTotal从8提到50,maxWaitMillis设1000,同时把大key的读写逻辑优化掉,问题就消失了。
监控层面,至少要看三个指标:Redis的连接数、内存使用率、慢查询日志。登录token这类数据虽然单个很小,但用户量大、缓存不清理,会积累很多过期key,内存只增不减。虽然Redis过期key的清除是异步的,但如果内存紧张,建议定期主动扫描清理无用key,或者设置合理的maxmemory-policy,线上一般用allkeys-lru或volatile-lru,配合监控及时扩容。
5. 实操踩坑记录与新手指南
5.1 常见问题排查速查表
写代码是一回事,上线后排查问题又是一回事。我把实际工作中遇到的典型问题整理成一个速查表,基本覆盖了登录校验最常见的翻车点。
| 现象 | 可能原因 | 排查思路与解法 |
|---|---|---|
| 登录后马上请求接口还是401 | token没有写入成功,或写入的key和读取的key不一致 | redis-cli查login:token:xxx是否存在;确认前后台key前缀一致;确认拦截器路径是否配置正确 |
| 所有用户同时掉线 | 系统重启导致Redis数据丢失(没开持久化),或redis服务重启 | 检查Redis持久化配置(RDB/AOF);确认Redis部署不是内存型无持久化模式 |
| 用户反映"用着用着就退出登录" | 固定过期时间到了,或续期逻辑没有生效 | 查看拦截器里是否每个请求都重置了TTL;检查expire命令的参数单位是不是传错了 |
| 登录接口报错"Connection refused" | Redis服务没启动,或客户端连接配置错误 | 确认redis-server进程存在;从应用服务器测试telnet到Redis端口;检查密码配置 |
| 多实例部署后,登录状态随机丢失 | 没有把所有实例的Redis地址配成同一个 | 检查每台应用的配置中心/配置文件;确认没混用不同环境Redis |
| 管理后台踢人没效果 | 踢人逻辑只删了设备集合,没删token key | 踢人时必须两步都删:删login:token:{token},再删设备集合里的token |
| 登录token在Redis里看到一堆乱码 | 用了Jdk序列化 | 切String/JSON序列化,新老key注意兼容处理,必要时可以写一个数据迁移脚本 |
| Redis缓存了大量无效token,内存持续上涨 | 过期时间设置过长、滑动续期无限续期 | 缩短TTL;启动清理任务;排查是否有循环请求不断续期 |
5.2 从安装到可视化:新手最容易卡住的几步
经常在各种技术群里看到新手在Redis安装和连接上卡住,连带着对登录校验的理解也打折。这里简单说几句,帮大家少走弯路。
Windows环境没有官方Redis版本,很多人搜"redis windows 下载"会找到一堆来路不明的包,安全风险不小。现在GitHub上有开源的Windows移植版,Redis 5.0.14.1这类的版本比较稳,下下来解压就能用,直接启动redis-server.exe。生产环境还是建议用Linux或Docker。Docker部署很简单,一条命令的事情,注意一定把数据目录挂载到宿主机,不然容器一删数据全没了。Mac用户可以用Homebrew或直接Docker,都很方便。
可视化客户端,以前老用Redis Desktop Manager,现在社区版体验也一般。可以试试Redis官方出的RedisInsight,功能完整,内存分析、慢查询都自带,适合排查问题。另外Another Redis Desktop Manager也是个不错的选择,界面清爽。说实话,生产环境排查问题,我一半时间在RedisInsight,另一半直接在redis-cli打命令,很多人忽略命令行工具,其实它是最快最直接的。
5.3 面试延伸:与登录校验相关的Redis高频考点
最后聊点学习存货。现在面试Java后端,Redis基本是必问项,"基于Redis实现登录校验"本身就可以作为一个引子,把Redis的核心知识点串起来。你要是能把这个项目讲透,很多八股其实都能落到实处。
首先是Redis数据类型。你讲了登录校验用String、Hash、Set,面试官追问"为什么不用ZSet",你如果有真实项目经验,能回答"ZSet适合排行榜这类按分数排序的场景,登录校验不需要排序,用Set就可以了",这就比背八股强。
其次是过期删除策略。登录态token依赖TTL,面试官会问Redis是怎么清理过期key的。主动删除+惰性删除结合,后台定期抽样删除,访问时发现过期再去删。你结合自己的token设计来讲,就说"大量token过期时,Redis并不会立刻全部删除,所以内存可能还会涨一会儿",这就是实战经验。
再就是持久化机制。好多新手没想过Redis重启后登录态还在不在。如果没开持久化,Redis一重启,内存数据全丢,所有用户登录态瞬间失效。线上必须配RDB和AOF,RDB做快照恢复,AOF做数据追加,两个都开。这部分结合"系统重启后全员掉线"的实际事故去讲,比单纯背概念有说服力得多。
最后是分布式锁。登录接口防重复提交、单端登录踢人,都会用到分布式锁。从Redis实现锁的setnx语法,到Redisson的看门狗续期,再到锁的原子性释放,这条链子捋下来,面试官想深挖也就挖到这里了。
说到底,登录校验听起来简单,真正把它做成一个能扛住生产压力的方案,牵扯到的Redis知识点相当多。从数据结构选型、过期策略、序列化方式,到缓存治理、连接池、持久化配置,每一个细节都可能成为线上稳定的胜负手。
我个人在实际操作中有个很深的体会:一个看似基础的登录校验功能,最容易出问题的往往不是登录本身,而是那些"顺带一提"的细节——比如序列化配置不对、key设计不规范、忘记续期。这些小问题单看都不致命,合在一起能让一个系统显得特别脆。所以每次做这类功能,我都会把Redis相关的配置、监控、清理机制从头到尾过一遍,宁可多花一小时的准备时间,也懒得在凌晨两点的报警群里花三小时救火。希望这篇复盘能让大家少踩几个坑。