☰
谷粒商城单点登录实战:令牌+Redis实现多端登录与分布式会话
2026/10/26 20:20:01 网站建设 项目流程

单点登录这个词,做过电商项目的人应该都不陌生。我在带团队做谷粒商城这套实战链路的时候,走到业务模块这一块,单点登录是绕不过去的一道坎。为什么?因为商城的业务形态天然就是多端并存的——用户可能在PC网页上浏览商品,在手机App里加购物车,在小程序里下单,甚至在后台管理系统里处理订单。如果每个端都维护一套独立的登录状态,用户每切换一个端就要重新输一次账号密码,这种体验放在今天的电商环境里基本等于劝退。所以单点登录要解决的核心问题就一个:一次登录,多端通行,且各端状态保持一致。

这篇文章面向的是已经有一定Java Web基础、正在做分布式项目实战的开发者,尤其是跟着商城类项目练手的朋友。我会把单点登录在谷粒商城这个业务场景下的完整落地方案拆开来讲,从架构选型到代码实现,从踩坑记录到排查技巧,尽量做到你看完就能对着自己的项目复现。涉及到的核心技术点包括分布式会话、令牌机制、跨域携带、拦截器校验以及登录态续期,这些内容我会结合商城业务的实际需求来讲,不会干巴巴地堆概念。

1. 单点登录在商城业务中的整体设计思路

1.1 为什么商城项目必须做单点登录

先想清楚一件事:商城的用户行为链路是什么样的。一个典型用户早上在电脑上逛商品,把几件东西加了购物车,中午用手机App打开想看看有没有优惠券,晚上回家又用平板下单。这条链路里涉及至少三个客户端,如果每个客户端各自维护一套session,那用户在PC上加的购物车,手机端根本读不到,因为服务端认不出这是同一个人。这不是技术炫技的问题,是业务能不能跑通的问题。

谷粒商城这类分布式商城项目,服务拆分之后问题更明显。用户服务、商品服务、购物车服务、订单服务各自独立部署,用户登录状态如果只存在某一个服务的session里,其他服务根本拿不到。传统的单机session方案在分布式环境下直接失效,因为请求经过负载均衡之后可能落到不同节点上,session不共享就意味着登录态丢失。

所以单点登录在这个场景下的本质需求可以归纳为三条:第一,用户在一个端登录后,其他端能识别到同一个用户身份;第二,登录状态要在多个微服务之间共享;第三,令牌要有合理的生命周期管理,既不能永久有效带来安全风险,也不能太短导致用户频繁重新登录。

1.2 方案选型:为什么最终落在令牌加缓存的组合上

单点登录的实现方案市面上常见的有几种,我逐一说说为什么在商城项目里最终选了令牌加分布式缓存这条路。

第一种是session共享方案,把session统一存到Redis里,各服务从Redis读session。这个方案改动小,但有个硬伤:它依赖cookie机制,跨域场景下cookie携带很麻烦,尤其是前后端分离之后,前端域名和后端接口域名往往不一致,cookie的SameSite策略和跨域限制会让这套方案很难受。商城项目现在基本都是前后端分离的,所以这条路走起来别扭。

第二种是纯JWT方案,把用户信息编码进令牌本身,服务端不存状态,靠签名校验。这个方案无状态、扩展性好,但问题在于令牌一旦签发就无法主动失效。用户点了退出登录,或者管理员要踢掉某个异常账号,纯JWT做不到即时生效,只能等令牌自然过期。商城的登录态管理对安全性要求不低,这个缺陷不能接受。

第三种就是令牌加分布式缓存的组合方案,也是谷粒商城实战里采用的方式。服务端生成一个随机令牌返回给客户端,客户端每次请求带上这个令牌,服务端拿令牌去Redis里查对应的用户信息。这个方案兼顾了令牌的灵活性和缓存的可控性:令牌可以随时在服务端删除实现强制下线,用户信息存在Redis里可以设置过期时间实现自动续期,而且不依赖cookie,跨域携带用请求头就行。

我实测下来,这套组合方案在商城这种多端多服务的场景里是最稳的。它的核心逻辑用一句话概括就是:令牌是钥匙,Redis是钥匙对应的柜子,柜子里放着用户信息,服务端认钥匙不认人。

1.3 整体链路是怎么串起来的

把整个单点登录的链路拆开看,其实就三个环节:登录签发、请求校验、状态续期。

登录签发环节,用户在任意一个端输入账号密码,请求打到用户服务或者专门的认证服务,认证通过之后服务端生成一个全局唯一的令牌,把用户信息序列化后存进Redis,key用令牌,value用用户信息,同时设置一个过期时间。然后把令牌返回给客户端,客户端自己存起来,浏览器可以放localStorage,App放本地存储。

请求校验环节,客户端后续每次请求都在请求头里带上这个令牌,服务端有个拦截器统一拦截,从请求头取出令牌,去Redis查用户信息,查到了就放行并把用户信息塞进请求上下文,查不到就返回未登录状态让客户端跳转登录页。

状态续期环节,用户每次带着有效令牌访问,服务端在校验通过后刷新Redis里这个key的过期时间,这样只要用户在活跃使用,登录态就不会断,只有长时间不操作才会自然过期。这个细节很关键,不然用户用着用着突然被踢出去,体验很差。

这三个环节串起来,就构成了一个完整的单点登录闭环。下面我会把每个环节的实现细节掰开讲。

2. 令牌设计与缓存存储的核心细节

2.1 令牌该怎么生成才靠谱

令牌生成看起来简单,实际上有几个坑。最忌讳的就是用自增ID或者时间戳这种可预测的值做令牌,攻击者稍微猜一下就能伪造身份。谷粒商城实战里用的是UUID去掉横杠,或者用更安全的随机字符串生成方式。

我一般推荐用UUID加时间戳再拼一个随机盐做一次哈希,这样既保证了唯一性又增加了不可预测性。代码大概长这样:

public String generateToken() { String raw = UUID.randomUUID().toString().replace("-", "") + System.currentTimeMillis() + new Random().nextInt(10000); return DigestUtils.md5DigestAsHex(raw.getBytes()); }

这里用MD5只是做一次摘要让长度固定,不是说MD5本身有多安全。真正保证安全的是原始输入的随机性足够强。UUID本身已经是128位随机值,加上时间戳和随机数,碰撞概率基本可以忽略。

令牌的长度也要考虑。太短容易被暴力枚举,太长增加传输开销。32位十六进制字符串(128位)是个比较均衡的选择,实测下来在商城这种QPS不算极端的场景里完全够用。

注意:令牌生成之后绝对不能在日志里明文打印。我见过有项目为了方便调试把令牌打进日志,结果日志文件被拖库,所有在线用户的登录态直接泄露。调试的时候最多打印令牌的前几位做标识。

2.2 Redis里存什么、怎么存

令牌生成之后要往Redis里写用户信息,这里的存储结构设计直接影响后续的查询效率和扩展性。

最直接的做法是用String类型,key是令牌,value是用户信息的JSON字符串。这个方案简单,查询快,适合用户信息字段不多的场景。但如果用户信息字段很多,每次校验都要反序列化整个JSON,开销就上来了。

另一种做法是用Hash类型,key是令牌,field是用户各个属性,value是对应值。这样查询单个字段很方便,但整体读取还是要遍历。谷粒商城实战里用户信息字段不算特别多,用String存JSON是最省事的,我一般也推荐这个。

存储的时候有几个细节要注意。第一,用户信息里不要存敏感字段,比如密码哈希、支付密码这些绝对不能进缓存,缓存一旦泄露就是大事故。第二,要存一个登录时间戳,方便后续做登录态分析和异常检测。第三,要存客户端的标识,比如是PC端还是App端登录的,这样后续做多端管理的时候有依据。

过期时间的设置是个权衡。设太短,用户稍微走神就掉线;设太长,安全风险增加。商城的常规做法是30分钟到2小时之间,我一般设30分钟,配合续期机制,活跃用户不会掉线,不活跃的用户半小时后自动失效。这个值可以通过配置中心动态调整,不同业务场景用不同值。

// 存储用户登录信息 String token = generateToken(); String userJson = JSON.toJSONString(userInfo); redisTemplate.opsForValue().set("login:token:" + token, userJson, 30, TimeUnit.MINUTES);

key的命名前缀login:token:是个好习惯,方便在Redis里按业务归类,也避免和其他业务的key冲突。这个前缀在后续做批量清理或者监控的时候特别有用。

2.3 多端登录的策略选择

商城业务里有个绕不开的问题:同一个账号在多个端同时登录,怎么处理?

有三种策略。第一种是互斥登录,新端登录把老端的令牌顶掉,同一时间只允许一个端在线。这个策略安全性最高,但用户体验差,用户在电脑上登录着,手机一登录电脑就掉线,很烦。

第二种是并行登录,每个端登录生成独立令牌,互不影响。这个体验好,但服务端要维护一个用户到多个令牌的映射关系,管理复杂度上升。

第三种是限制数量,比如最多允许三个端同时在线,超过就把最早的踢掉。这个折中方案在商城场景里比较常见。

谷粒商城实战里为了简化,一般先做并行登录,每个端独立令牌。如果要实现互斥或者限数,需要在Redis里额外维护一个user:token:{userId}的集合,记录这个用户当前所有有效令牌,登录时做判断和清理。这块逻辑不难,但要想清楚业务上到底需要哪种策略,别一上来就做最复杂的。

3. 登录签发与请求校验的完整实现

3.1 登录接口的完整流程

登录接口是整个单点登录的入口,它的流程可以拆成五步:参数校验、账号密码验证、生成令牌、写入缓存、返回结果。

参数校验这步别省。我见过有项目直接拿前端传来的账号密码去查库,结果传了个空值进去导致全表扫描。基本的非空校验、长度校验、格式校验都要做,用注解或者手动判断都行。

账号密码验证这步,密码比对一定要用加密后的比对,不能明文比。商城项目里用户密码一般是加盐哈希存储的,登录时把用户输入的密码用同样的盐和算法哈希一遍再比对。这里有个细节:不管账号不存在还是密码错误,返回给前端的提示要统一成"账号或密码错误",不能区分,否则等于告诉攻击者哪些账号是存在的。

生成令牌和写入缓存前面讲过了,这里重点说返回结果的设计。返回给前端的不能只有令牌,还要有用户的基本信息(昵称、头像这些),这样前端登录后能直接渲染用户区域,不用再发一次请求。但返回的用户信息里同样不能有敏感字段。

public Map<String, Object> login(String account, String password) { // 1. 参数校验 if (StringUtils.isBlank(account) || StringUtils.isBlank(password)) { throw new BizException("账号或密码错误"); } // 2. 查用户 User user = userMapper.selectByAccount(account); if (user == null) { throw new BizException("账号或密码错误"); } // 3. 密码比对 String encrypted = encryptPassword(password, user.getSalt()); if (!encrypted.equals(user.getPassword())) { throw new BizException("账号或密码错误"); } // 4. 生成令牌并写缓存 String token = generateToken(); UserVO userVO = buildUserVO(user); redisTemplate.opsForValue().set("login:token:" + token, JSON.toJSONString(userVO), 30, TimeUnit.MINUTES); // 5. 返回 Map<String, Object> result = new HashMap<>(); result.put("token", token); result.put("user", userVO); return result; }

这段代码里buildUserVO方法负责把数据库实体转成前端需要的视图对象,过滤掉敏感字段。这个转换层别省,直接返回实体类很容易把不该给的字段带出去。

3.2 拦截器统一校验登录态

请求校验的核心是一个拦截器,它负责在请求进入业务逻辑之前把登录态验掉。这个拦截器要注册到Web配置里,并且要配置好哪些路径需要拦截、哪些放行。

放行的路径包括登录接口本身、注册接口、商品浏览这类不需要登录的公开接口。需要拦截的是购物车、订单、用户中心这些涉及个人数据的接口。这个白名单和黑名单的配置要仔细梳理,漏配了会导致该拦的没拦,多配了会导致公开接口也要求登录。

拦截器的逻辑不复杂:从请求头取令牌,判空,去Redis查,查到就放行并把用户信息塞进ThreadLocal,查不到就返回401。

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { response.setStatus(401); return false; } String key = "login:token:" + token; String userJson = redisTemplate.opsForValue().get(key); if (StringUtils.isBlank(userJson)) { response.setStatus(401); return false; } // 续期 redisTemplate.expire(key, 30, TimeUnit.MINUTES); // 塞入上下文 UserVO user = JSON.parseObject(userJson, UserVO.class); UserContext.set(user); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }

这里用ThreadLocal存用户信息是为了让后续的业务代码能方便地拿到当前登录用户,不用一层层传参。但ThreadLocal用完一定要清理,afterCompletion里那句UserContext.clear()不能少,否则线程池复用的时候会出现用户信息串号的问题,这是个很隐蔽的坑。

提示:拦截器里返回401之后,前端要统一处理跳转登录页。这个逻辑放在前端的请求响应拦截器里做,别在每个接口里单独判断。

3.3 跨域场景下令牌怎么带

前后端分离之后,跨域是必然要面对的。令牌放在请求头里携带,跨域的时候浏览器会先发一个预检请求(OPTIONS),服务端要正确响应预检,否则真正的请求根本发不出去。

服务端要配置CORS,允许前端域名、允许携带自定义请求头(Authorization)、允许必要的方法。这里有个细节:如果前端要携带cookie,allowCredentials要设true,但同时allowedOrigins不能用通配符*,必须指定具体域名。不过我们这套方案令牌走请求头不走cookie,所以allowCredentials可以设false,配置上简单一些。

前端这边,每次请求都要在请求拦截器里把令牌塞进请求头。令牌存在localStorage里,请求拦截器统一读取并附加。响应拦截器统一处理401,清掉本地令牌并跳转登录页。

// 请求拦截器 axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = token; } return config; }); // 响应拦截器 axios.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); window.location.href = '/login'; } return Promise.reject(error); } );

这套前端拦截逻辑是单点登录体验的关键,它让令牌的携带和失效处理对业务代码完全透明,业务代码只管发请求,不用关心登录态。

4. 登录态续期与退出登录的细节处理

4.1 续期机制怎么设计才不突兀

续期这件事,做得好用户无感,做得不好就是各种莫名其妙的掉线。前面拦截器里那句redisTemplate.expire就是续期的核心,每次有效请求都把过期时间重置回30分钟。

但这里有个问题:如果用户一直开着页面不操作,但页面本身有轮询请求(比如消息通知),那这些请求也会触发续期,导致登录态永远不过期。这个行为对不对取决于业务,商城的做法一般是区分"活跃请求"和"后台请求",只有用户主动触发的请求才续期,后台轮询不续期。实现上可以在请求头里加个标识,或者按接口路径区分。

另一个细节是续期的时机。放在拦截器里每次请求都续,Redis的写入压力会比较大。优化做法是判断当前剩余过期时间,如果还大于某个阈值(比如20分钟)就不续,只有快过期了才续。这样能减少大量无谓的写入。

Long expire = redisTemplate.getExpire(key, TimeUnit.MINUTES); if (expire != null && expire < 20) { redisTemplate.expire(key, 30, TimeUnit.MINUTES); }

这个优化在高并发场景下效果明显,我实测过,能减少大概六成以上的expire操作。

4.2 退出登录要清理干净

退出登录的逻辑很简单:从请求头拿令牌,删掉Redis里对应的key,返回成功。但简单归简单,有几个地方容易漏。

第一,前端本地存的令牌也要清掉,否则用户点了退出,本地还留着令牌,下次请求又带上了,虽然后端已经删了会返回401,但体验上会有一次多余的失败请求。

第二,如果做了多端管理,退出的时候要考虑是只退当前端还是退所有端。只退当前端就删当前令牌,退所有端就要根据用户ID找到所有令牌批量删除。这个需求要提前和产品确认清楚。

第三,退出接口本身要允许未登录状态访问,因为令牌可能已经过期了,用户点退出的时候令牌失效,这时候接口不能因为校验不过而报错,应该直接返回成功。

public void logout(String token) { if (StringUtils.isNotBlank(token)) { redisTemplate.delete("login:token:" + token); } }

这个方法不抛异常,令牌为空或者不存在都静默处理,保证退出操作永远成功。

4.3 强制下线的实现方式

强制下线是运营和风控的刚需。比如用户举报账号被盗,或者系统检测到异常登录,需要立刻让某个用户的登录态失效。

实现方式取决于令牌的存储结构。如果只存了login:token:{token},那要强制下线某个用户,得先知道这个用户有哪些令牌。这就需要在登录时额外维护一个user:tokens:{userId}的集合,记录该用户所有有效令牌。强制下线时从这个集合取出所有令牌,逐个删除,再删集合本身。

public void forceLogout(Long userId) { String userTokenKey = "user:tokens:" + userId; Set<String> tokens = redisTemplate.opsForSet().members(userTokenKey); if (tokens != null) { for (String token : tokens) { redisTemplate.delete("login:token:" + token); } redisTemplate.delete(userTokenKey); } }

这个集合的维护要注意同步:登录时加进去,退出时移除,令牌自然过期时集合里的残留项要定期清理。可以给集合也设一个稍长的过期时间,或者用定时任务扫描清理。

5. 实操中踩过的坑与排查技巧

5.1 令牌失效但用户以为还登录着

这个问题的表现是:用户明明刚登录过,操作一会儿就提示未登录。排查下来最常见的原因是续期没生效。可能的情况有几种:拦截器里忘了写expire那行;expire的key拼错了;或者续期逻辑被某个条件分支跳过了。

排查方法很直接:登录后拿令牌去Redis里TTL一下看剩余时间,然后发一个业务请求,再TTL一次,看时间有没有重置。如果没重置,就顺着拦截器代码查续期逻辑。我一般会在拦截器里加一行debug日志打印续期前后的TTL,定位起来很快。

还有一种隐蔽情况是Redis的过期策略配置问题,或者Redis内存满了触发了淘汰。这种要看Redis的监控指标,evicted_keys如果持续增长,说明内存在淘汰key,登录态被误淘汰了。解决办法是给登录相关的key设置更高的优先级,或者扩容Redis。

5.2 多服务之间用户信息不一致

分布式环境下,用户信息在多个服务间传递,容易出现不一致。比如用户在用户中心改了昵称,但购物车服务里缓存的还是旧昵称。

根因是用户信息在登录时被快照进了Redis,后续用户信息变更没有同步更新缓存。解决办法有两个:一是用户信息变更时主动更新所有相关令牌对应的缓存,这个实现复杂但实时性好;二是缓存里只存用户ID和基础标识,具体信息各服务按需查库或查独立的用户信息缓存,这个实现简单但多一次查询。

商城的做法一般是折中:缓存里存用户ID、昵称、头像这些高频展示字段,变更时同步更新;其他低频字段不缓存,用到时再查。这样既保证了核心体验,又控制了同步的复杂度。

5.3 常见问题速查表

问题现象可能原因排查方向解决方式
登录后立即提示未登录令牌未正确写入缓存检查Redis写入代码和key拼写修正写入逻辑,确认TTL正常
用一会儿就掉线续期未生效对比请求前后TTL变化补上expire逻辑,检查条件分支
跨域请求令牌丢失CORS未允许自定义头看浏览器控制台预检请求配置allowedHeaders包含Authorization
退出后仍能访问只清了前端没清后端检查退出接口是否删了缓存补上Redis删除逻辑
用户信息串号ThreadLocal未清理检查afterCompletion确保每次请求后clear
强制下线不生效未维护用户令牌集合检查user:tokens集合登录时维护集合,下线时批量删

这张表是我在实际项目中反复用到的排查清单,遇到登录态问题先对着表过一遍,大部分情况能快速定位。

5.4 几个容易被忽略的安全细节

令牌在传输过程中要走HTTPS,这个不用多说,明文传输的令牌等于把钥匙挂在门上。本地开发环境可能用HTTP,但上线必须HTTPS。

令牌的有效期不要设太长,前面说的30分钟配合续期是合理的。有些项目为了省事设7天,这期间令牌泄露就是7天的风险窗口。

登录接口要做频率限制,防止暴力破解。同一个账号或同一个IP连续失败多次就锁定一段时间,这个用Redis的计数器很容易实现。

敏感操作(比如修改密码、支付)要二次验证,不能光凭令牌就放行。令牌只能证明"你是登录用户",不能证明"当前操作是你本人意愿"。

6. 这套方案后续可以怎么扩展

单点登录做完基础版之后,其实还有很多可以深挖的方向。比如接入第三方登录,微信、支付宝这些,本质上是把认证环节外包出去,拿到第三方凭证后在自己的体系里换发令牌,整体链路和账号密码登录是一致的,只是认证那一步换了实现。

再比如做登录设备管理,让用户能看到自己账号在哪些设备上登录着,可以手动踢掉某个设备。这个功能依赖前面说的用户令牌集合,把设备信息也存进去就行。

还有令牌的无感刷新,现在这套方案是滑动过期,用户活跃就不掉线。但如果要做更精细的控制,可以引入双令牌机制,一个短期的访问令牌加一个长期的刷新令牌,访问令牌过期用刷新令牌换新的,这样安全性和体验能兼顾得更好。

我个人在实际操作中的体会是,单点登录这套东西原理不复杂,难的是细节的完整性和一致性。登录、校验、续期、退出、强制下线,每个环节都要考虑到,任何一个环节漏了都会在某个场景下暴露问题。建议做完之后自己把多端登录、长时间挂机、强制下线这几个场景都手动过一遍,比看代码靠谱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询