资讯动态

Redis+Lua实现高并发单点登录原子控制

发布时间:2026/10/15 22:19:00 来源:尧图企业网站定制
简介本资源是一套基于JSP技术实现“同一账号单点登录”功能的完整Web应用工程面向Java Web初学者与中级开发者解决多设备并发登录场景下的会话冲突与账户安全管控问题。压缩包共35个文件含6个核心Java业务类如SessionManager、LoginFilter、4个JSP页面含index.jsp登录入口、6个编译后class文件、15个TLD标签库文件支撑权限控制以及2个依赖jar包和1个web.xml配置文件整体结构体现典型ServletJSPFilter架构设计包体大小为351KB。目前已有1409人学习下载资源可直接部署运行提供从登录验证、session绑定、多端互斥检测到旧会话强制失效的全流程实现逻辑附带清晰目录组织含src、WEB-INF、META-INF及admin管理模块便于理解单点登录在传统Java Web环境中的落地细节与工程化实践。1. 同一个账号同一时间只能允许他登录一次为什么看似简单的业务规则却让80%的后端服务在高并发下悄悄“漏人”“同一个账号同一时间只能允许他登录一次”——这句需求常出现在登录鉴权模块评审会上语气平静像在说“用户密码要加密存储”一样理所当然。但真正落地时它会立刻撕开理想与现实的裂缝当A用户在手机App刚登录成功又用PC浏览器发起第二次登录请求系统该不该踢掉前一个如果踢是立刻中断TCP连接还是等下次接口调用才返回401如果A在地铁里断网3分钟重连时发现“已被强制下线”而他自己根本没主动登出——这个“被踢”算合理吗更棘手的是当服务部署在3台节点上Redis集群主从同步有200ms延迟A在节点1登录、0.1秒后在节点2重复登录此时两节点各自查本地缓存都显示“未登录”结果双双放行……这种分布式环境下的状态竞态不是靠加个if (isLogin(user))就能拦住的。本文面向已写过JWT鉴权、熟悉Spring Security或自研Token体系的中高级后端开发者不讲OAuth2协议演进只聚焦如何在真实生产环境中用可验证、可回滚、低侵入的方式把“单点登录”从一句需求变成稳定运行三年不翻车的原子能力。你会看到为什么Redis SETNX不是万能解药、Session过期时间与心跳续期的黄金配比、前端如何配合做无感踢出、以及最关键的——当Redis挂了你的服务是直接拒绝所有新登录还是优雅降级为“允许重复登录但记录告警”2. 用RedisLua实现原子性校验为什么不用SETNX而选EVAL脚本2.1 为什么SETNX在分布式场景下会失效初学者常这样写# 伪代码先查再设 if redis.get(login:uid:1001) is None: redis.setex(login:uid:1001, 3600, session_id_xxx) return success else: return login_conflict这段代码在单机Redis上看似可行但一旦服务横向扩展问题立刻暴露网络延迟导致竞态节点A读取login:uid:1001返回空此时key尚未过期正准备执行setex节点B在同一毫秒也读到空两个节点都执行setex结果同一个用户获得两个有效session。主从同步延迟放大风险即使使用Redis Cluster主节点写入后从节点同步存在延迟B节点可能从从节点读取到旧状态nil误判为可登录。提示SETNXSET if Not eXists虽能避免“先查后设”的竞态但它无法同时完成“设置过期时间写入值返回旧值”三个动作——而我们的业务需要知道“本次登录是否踢掉了前一个session”以便触发通知或清理逻辑。2.2 用Lua脚本封装原子操作一次通信完成全部判断Redis保证Lua脚本的原子性执行同一实例内我们编写如下脚本保存为check_and_set_login.lua-- KEYS[1]: 用户唯一标识键如 login:uid:1001 -- ARGV[1]: 当前登录生成的session_id -- ARGV[2]: 过期时间秒 -- 返回值0已登录且未踢出1新登录成功2踢出旧session并登录成功 local current_key KEYS[1] local new_session ARGV[1] local expire_sec tonumber(ARGV[2]) -- 获取当前key的旧值可能为nil local old_session redis.call(GET, current_key) -- 情况1key不存在或已过期 → 直接设置新session if old_session false or old_session nil then redis.call(SETEX, current_key, expire_sec, new_session) return 1 end -- 情况2key存在且值不为空 → 比较是否为同一session防重复提交 if old_session new_session then -- 同一session刷新过期时间保持在线 redis.call(EXPIRE, current_key, expire_sec) return 0 end -- 情况3key存在且为不同session → 踢出旧session设置新session redis.call(SETEX, current_key, expire_sec, new_session) return 2关键参数说明KEYS[1]必须是带业务前缀的唯一键如login:uid:1001严禁用login:user1001这种易冲突格式——某次压测中因前缀不统一导致用户1001和10010的键哈希到同一slot引发误踢ARGV[2]过期时间建议设为36001小时而非8640024小时过长的TTL会导致异常退出的session长期残留占用内存且影响踢出准确性返回值2表示“踢人成功”业务层需在此刻调用publish通知其他节点清理本地缓存后文详述。2.3 Java中调用Lua脚本的完整实现Spring Data RedisComponent public class SingleLoginService { Autowired private RedisTemplateString, String redisTemplate; // 预加载Lua脚本应用启动时执行一次 private final DefaultRedisScriptLong loginScript; public SingleLoginService() { this.loginScript new DefaultRedisScript(); this.loginScript.setScriptSource(new ResourceScriptSource( new ClassPathResource(lua/check_and_set_login.lua))); this.loginScript.setResultType(Long.class); } /** * 尝试登录原子性检查并设置登录状态 * param userId 用户ID * param sessionId 当前登录生成的唯一session ID * return LoginResult 枚举NEW_LOGIN / REFRESH / KICKED_AND_LOGIN */ public LoginResult tryLogin(Long userId, String sessionId) { String key login:uid: userId; Long result redisTemplate.execute( loginScript, Collections.singletonList(key), sessionId, 3600 // TTL秒数硬编码便于灰度控制 ); switch (result.intValue()) { case 0: return LoginResult.REFRESH; case 1: return LoginResult.NEW_LOGIN; case 2: return LoginResult.KICKED_AND_LOGIN; default: throw new IllegalStateException(Unexpected lua return: result); } } }逻辑说明DefaultRedisScript是Spring Data Redis提供的Lua脚本封装类execute()方法将KEYS和ARGV透传给RedisCollections.singletonList(key)构造单元素列表作为KEYS参数符合Lua脚本中KEYS[1]的索引约定LoginResult.KICKED_AND_LOGIN触发后必须立即向消息队列发布事件如Redis Pub/Sub的channel:logout:uid:1001通知所有服务节点作对应处理——这是跨节点状态同步的核心环节。3. 前端无感踢出方案从“弹窗提示”到“静默迁移”的三次迭代3.1 第一代方案弹窗强提示已淘汰早期做法是在前端轮询/api/v1/session/status接口返回{code:401,msg:账号已在其他设备登录}时弹出全屏遮罩并强制跳转登录页。问题极其明显用户正在填写长表单突然弹窗导致输入内容丢失移动端APP后台切前台时因token失效触发弹窗用户误以为APP崩溃网络抖动导致误判1分钟内连续弹3次“被踢出”投诉率飙升。血泪经验任何需要用户点击“确定”才能继续的操作在单点登录场景下都是反模式。真正的“单点”必须对用户透明。3.2 第二代方案Token自动续签 后台静默注销核心思想不阻断当前操作而是让旧session自然失效新session接管后续请求。具体流程后端检测到KICKED_AND_LOGIN时除设置新key外额外向Redis写入一个临时标记SETEX kick_mark:uid:1001:old_session_id 60 true60秒过期确保标记不长期占用内存所有API网关层拦截器增加逻辑解析请求Header中的Authorization: Bearer xxx若xxx匹配kick_mark:uid:1001:*模式则返回401并携带HeaderX-Login-Kicked: true前端Axios拦截器捕获此Headeraxios.interceptors.response.use( response response, error { if (error.response?.headers?.[x-login-kicked] true) { // 不弹窗仅清除本地token保留当前页面状态 localStorage.removeItem(auth_token); // 下次请求自动携带新token由登录态管理模块注入 return Promise.resolve(); // 让原请求重试 } return Promise.reject(error); } );优势用户无感知表单不丢失APP切前台不闪退。但仍有缺陷若用户长时间未发起新请求旧session仍可继续调用接口直到60秒后标记过期。3.3 第三代方案WebSocket实时通道 前端状态快照终极解法是建立双向通信通道。我们在登录成功后由后端主动推送WebSocket连接指令// 登录成功后向用户专属频道推送连接指令 String channel ws:uid: userId; redisTemplate.convertAndSend(channel, JsonUtils.toJson(Map.of(cmd, connect_ws, token, newSessionId)));前端监听该频道通过SSE或WebSocket收到指令后立即建立长连接并将当前页面状态序列化如{form_data: {...}, scroll_y: 1200, active_tab: detail}发送至服务端暂存此后所有API请求改用新token旧token在首次使用时即被网关拦截并返回401若用户在踢出后5秒内重新操作前端从本地恢复状态快照实现“无缝迁移”。参数关键点WebSocket连接必须绑定userId而非sessionId否则踢出后无法定位新连接状态快照大小限制在10KB以内超限则只保存关键字段如当前路由、表单ID避免内存泄漏服务端暂存快照使用SETEX ws_snapshot:uid:1001 300 json...5分钟自动过期防止堆积。4. 分布式环境下的三大避坑指南Redis故障、时钟漂移、心跳失联4.1 Redis集群全宕机时服务应拒绝登录还是降级放行现象某次机房电力波动导致Redis Cluster 5个节点全部不可用登录接口大量超时监控显示redis.timeout错误率100%。原因原始代码中tryLogin()方法未设置Redis操作超时且未定义降级策略导致线程池被占满整个服务雪崩。解决在RedisTemplate配置中强制设置超时lettuceClientConfigurationBuilder .commandTimeout(Duration.ofMillis(200)) // 关键必须≤300ms .shutdownTimeout(Duration.ofSeconds(2));tryLogin()方法增加熔断逻辑if (redisTemplate.getConnectionFactory().getConnection().ping() null) { log.warn(Redis unavailable, fallback to allow login with warning); // 写入本地ConcurrentHashMap缓存仅限单机维度 localLoginCache.put(userId, sessionId); return LoginResult.NEW_LOGIN; // 降级允许登录但记录审计日志 }注意降级模式下必须开启审计日志记录uid,ip,timestamp,fallback_reason便于事后追溯异常登录。4.2 服务器时钟不同步导致Session提前过期现象运维同学反馈部分用户频繁收到“登录已过期”提示但实际距离登录仅过去10分钟。原因排查发现某台应用服务器NTP服务异常系统时间比标准时间快15分钟导致SETEX设置的3600秒TTL在Redis中实际只存活3450秒。解决所有服务器强制启用NTP校时systemctl enable chronyd chronyc tracking在登录逻辑中加入时钟偏移校验// 获取Redis服务器时间毫秒级 Long redisTime redisTemplate.execute((RedisCallbackLong) con - { return con.time().getSeconds(); }); long systemTime System.currentTimeMillis() / 1000; long offset Math.abs(redisTime - systemTime); if (offset 30) { // 偏移超30秒触发告警 alarmService.send(ClockOffsetAlert, offset offset s); }提示不要依赖System.currentTimeMillis()计算过期时间所有TTL值必须由Redis服务端解析避免客户端时钟误差。4.3 心跳续期失败导致“假踢出”现象用户在弱网环境下如电梯、隧道App每30秒发一次心跳但第3次心跳因网络丢包未到达Redis中session过期用户操作时突然登出。原因心跳接口未做幂等设计且未与前端重试机制对齐。解决心跳接口改为POST /api/v1/heartbeat参数含last_active_ts客户端本地时间戳服务端校验仅当last_active_ts redis.get(login:uid:1001:ts)时才更新TTL避免网络延迟导致的乱序覆盖前端SDK内置指数退避重试function sendHeartbeat() { const ts Date.now(); return axios.post(/api/v1/heartbeat, { last_active_ts: ts }) .catch(err { if (err.code ECONNABORTED) { setTimeout(() sendHeartbeat(), Math.pow(2, retryCount) * 1000); } }); }玄学参数重试间隔设为2^retryCount * 1000ms最大重试3次即1s→2s→4s覆盖99%弱网场景。5. 生产环境验证清单用5个真实case跑通你的单点登录链路5.1 验证场景设计原则覆盖“时间、空间、异常”三维度单点登录不是功能开关而是状态机。我们设计以下5个必验case每个case执行后必须检查三项指标✅ Redis中login:uid:*键是否存在且值正确✅ MySQL中user_login_log表是否记录对应事件type: LOGIN/KICK✅ 前端DevTools Network面板中目标请求是否返回预期HTTP状态码及Header。Case编号场景描述预期结果验证命令/操作C1同一用户在Chrome和Firefox中先后登录后者登录成功前者下次请求返回401X-Login-Kicked:truecurl -H Authorization: Bearer old_token http://api/user/profileC2用户登录后手动删除Redis中对应key下次请求返回401但前端不弹窗因无X-Login-KickedHeaderredis-cli DEL login:uid:1001C3模拟Redis响应超时mock网络延迟服务降级放行MySQL中login_log.reasonredis_timeout使用tc命令限速tc qdisc add dev eth0 root netem delay 500msC4两个请求几乎同时到达时间差1msRedis Lua脚本返回2KICKED_AND_LOGIN且仅一条KICK日志JMeter并发2线程循环1次Ramp-up0秒C5用户登录后关闭所有浏览器标签页30分钟后打开新标签页因session过期返回401且无X-Login-Kicked前端跳转登录页sleep 1800 curl ...5.2 自动化验证脚本用Python复现C4高并发场景# test_concurrent_login.py import redis import threading import time import json r redis.Redis(hostlocalhost, port6379, db0) def login_worker(user_id, session_id): # 模拟调用后端登录接口此处简化为直接执行Lua result r.eval( open(lua/check_and_set_login.lua).read(), 1, flogin:uid:{user_id}, session_id, 3600 ) print(fUser {user_id} login result: {result}) # 启动2个线程模拟并发登录 t1 threading.Thread(targetlogin_worker, args(1001, sess_a)) t2 threading.Thread(targetlogin_worker, args(1001, sess_b)) start_time time.time() t1.start() t2.start() t1.join() t2.join() print(fTotal time: {time.time() - start_time:.3f}s) # 验证最终状态 final_session r.get(login:uid:1001) print(fFinal session in Redis: {final_session})执行要点必须在redis.conf中设置timeout 0禁用空闲超时否则连接池可能干扰测试脚本输出中final_session应为bsess_b证明sess_a被成功覆盖若出现bsess_a说明Lua脚本未生效需检查Redis版本要求≥5.0及脚本语法。5.3 灰度发布检查表上线前最后10分钟要确认的7件事Redis Key命名规范确认所有环境dev/uat/prod的login:uid:*前缀一致无login:user*等历史遗留格式TTL参数一致性检查各环境配置中心中single_login.ttl_seconds值均为3600禁止prod用86400降级开关就绪确认application-prod.yml中feature.single-login.fallback-enabled: true已配置审计日志开关logback-spring.xml中logger namecom.xxx.SingleLoginAuditLogger levelINFO/已启用WebSocket连接池确认Netty线程池bossGroup和workerGroup大小≥CPU核心数×2前端SDK版本CDN中login-sdk-v2.3.1.min.js已全量发布旧版v1.x已下线应急预案文档RUNBOOK_SINGLE_LOGIN_FALLBACK.md已同步至运维知识库含redis down时手动清理localLoginCache的Java代码片段。我坚持在每次上线前用手机热点模拟弱网亲自走一遍C1-C5全流程——不是因为信不过自动化脚本而是因为真实用户的操作路径永远比case复杂。比如某次发现当用户在登录页连续点击“忘记密码”和“立即注册”按钮时前端会并发发出3个请求其中1个携带旧token导致踢出逻辑被意外触发。这种边界情况只有手动点出来才能捕捉。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑