
说实话登录校验这需求每个做后端的人都会遇到。早期我习惯用Session项目单体阶段挺顺手直到有一次线上服务扩容用户登录状态到处乱飘排查到半夜才意识到Session存在单机内存里多实例部署就是给自己埋雷。后来我们把登录态全部切到Redis一套方案同时解决了集群会话共享、过期控制、主动踢人这些问题今天就把这套基于Redis的登录校验实现从头到尾拆给大家。这套方案适合什么场景只要你需要在多个服务实例之间共享登录态或者想支持用户强制下线、设备管理、登录态续期又不想引入太重的认证框架那Redis登录校验就是一个性价比很高的选择。它不依赖特定语言框架思路是通用的后面用Spring Boot举例其他语言照着搬逻辑就行。1. 为什么登录校验要引入Redis1.1 从Session到Redis登录状态到底该存哪儿先说说之前用Session踩过的坑。Session默认存在服务端内存里用户登录后Session ID通过Cookie写回浏览器下次请求带着这个ID来服务端从内存里把Session对象取出来。单机没问题但一旦做了负载均衡同一个用户的请求被分发到不同机器就麻烦了A机器存的SessionB机器取不到用户一会儿登录一会儿掉线。常规解法有几种一是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 → 用户IDStringlogin:token:{token}校验登录态时最频繁的查询O(1)取value天然合适用户 → 在线设备集合Setlogin:user:{userId}:devices存储该用户所有在线token支持踢人、查在线设备用户 → 设备详细信息Hashlogin:user:{userId}:info字段用token值存设备名、登录时间、IP等防重复提交/限流Stringlogin:loginAttempt:{userId}带过期时间的计数器用于登录失败次数限制这里最核心的是前两个。为什么Token到用户ID用String而不是Hash因为校验请求时就是输入一个token要立刻查出用户IDString的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) { SetString tokens stringRedisTemplate.opsForSet().members(login:user: userId :devices); if (tokens ! null) { ListString 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并删除 SetString tokens stringRedisTemplate.opsForSet().members(login:user: userId :devices); ListString 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用StringRedisSerializervalue根据使用场景来定。如果value只是userId这种字符串直接用StringRedisTemplate就行默认就是String序列化完全不用操心。如果value需要存对象比如设备信息就用GenericJackson2JsonRedisSerializer配合一个带类型的ObjectMapper这样存进去的是JSON字符串可读性好而且能保留类型信息反序列化不会报ClassCastException。这里有一个细节使用JSON序列化后value字段在命令行里看就是普通JSONredis-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提到50maxWaitMillis设1000同时把大key的读写逻辑优化掉问题就消失了。监控层面至少要看三个指标Redis的连接数、内存使用率、慢查询日志。登录token这类数据虽然单个很小但用户量大、缓存不清理会积累很多过期key内存只增不减。虽然Redis过期key的清除是异步的但如果内存紧张建议定期主动扫描清理无用key或者设置合理的maxmemory-policy线上一般用allkeys-lru或volatile-lru配合监控及时扩容。5. 实操踩坑记录与新手指南5.1 常见问题排查速查表写代码是一回事上线后排查问题又是一回事。我把实际工作中遇到的典型问题整理成一个速查表基本覆盖了登录校验最常见的翻车点。现象可能原因排查思路与解法登录后马上请求接口还是401token没有写入成功或写入的key和读取的key不一致redis-cli查login:token:xxx是否存在确认前后台key前缀一致确认拦截器路径是否配置正确所有用户同时掉线系统重启导致Redis数据丢失没开持久化或redis服务重启检查Redis持久化配置RDB/AOF确认Redis部署不是内存型无持久化模式用户反映用着用着就退出登录固定过期时间到了或续期逻辑没有生效查看拦截器里是否每个请求都重置了TTL检查expire命令的参数单位是不是传错了登录接口报错Connection refusedRedis服务没启动或客户端连接配置错误确认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和AOFRDB做快照恢复AOF做数据追加两个都开。这部分结合系统重启后全员掉线的实际事故去讲比单纯背概念有说服力得多。最后是分布式锁。登录接口防重复提交、单端登录踢人都会用到分布式锁。从Redis实现锁的setnx语法到Redisson的看门狗续期再到锁的原子性释放这条链子捋下来面试官想深挖也就挖到这里了。说到底登录校验听起来简单真正把它做成一个能扛住生产压力的方案牵扯到的Redis知识点相当多。从数据结构选型、过期策略、序列化方式到缓存治理、连接池、持久化配置每一个细节都可能成为线上稳定的胜负手。我个人在实际操作中有个很深的体会一个看似基础的登录校验功能最容易出问题的往往不是登录本身而是那些顺带一提的细节——比如序列化配置不对、key设计不规范、忘记续期。这些小问题单看都不致命合在一起能让一个系统显得特别脆。所以每次做这类功能我都会把Redis相关的配置、监控、清理机制从头到尾过一遍宁可多花一小时的准备时间也懒得在凌晨两点的报警群里花三小时救火。希望这篇复盘能让大家少踩几个坑。