前言:登出不仅仅是“跳回登录页”
在传统的 Session 时代,登出只需要session.invalidate()。但在现代前后端分离、基于Token(Redis + MySQL)的架构中,登出变成了一个**“销毁凭证”**的系统工程。
如果登出逻辑写得不好,可能会出现“用户点了登出,复制之前的 Token 还能继续调用接口”,或者“明明登出了,过一会又自动登录了”的灵异现象。
本文将基于Access Token + Refresh Token的双令牌模式,手把手教你实现一个安全的登出逻辑。
一、 核心概念:双令牌机制 (Dual Token) 工作原理
在实现登出功能前,必须明确Access Token与Refresh Token的技术分工与交互流程。
1. 令牌定义与区别
- Access Token (访问令牌)
- 用途:作为访问业务接口的身份凭证。
- 时效:短时效(通常 30 分钟)。
- 存储:通常存储于Redis与数据库 (MySQL)中,验证使用redis以满足高频验证的性能需求。
- Refresh Token (刷新令牌)
- 用途:仅用于在 Access Token 过期后,获取新的 Access Token。
- 时效:长时效(通常 7 ~ 30 天)。
- 存储:通常存储于数据库 (MySQL)中,用于持久化状态管理。
2. 交互流程
- 初始化:用户通过账号密码登录,服务端生成并返回 Access Token 和 Refresh Token。
- 业务请求:客户端在 HTTP Header 中携带 Access Token 请求业务接口,服务端通过 Redis 校验通过后响应数据。
- 过期拦截:Access Token 过期(Redis Key 失效),客户端请求业务接口时,服务端返回401 Unauthorized。
- 令牌刷新:客户端拦截 401 响应,携带 Refresh Token 请求
/refresh-token接口。服务端校验数据库中 Refresh Token 的有效性,签发新的 Access Token。 - 重试:客户端使用新的 Access Token 重新发起之前的业务请求。
二、 登出的核心原理:斩草要除根
在双 Token 机制下,用户手里有两把钥匙:
- Access Token(短效,存 Redis与MySQL):用于日常访问接口。
- Refresh Token(长效,存 MySQL):用于在 Access Token 过期时换取新的。
完美的登出,必须同时销毁这两把钥匙。
- 只删 Access Token:用户会立刻掉线,但前端的 Axios 拦截器发现 401 后,会拿着 Refresh Token 偷偷去换把新钥匙,用户莫名其妙“复活”了。
- 只删 Refresh Token:用户现在的 Access Token 还没过期(比如还有 20 分钟),这 20 分钟内他依然能非法访问接口。
结论:登出 =删除 Redis与DB 中的 Access Token+删除 DB 中的 Refresh Token。
二、 代码实现 (Controller 层)
Controller 层非常简单,主要是提取请求头中的 Token,并调用 Service。
@Tag(name="管理后台 - 认证")@RestController@RequestMapping("/system/auth")publicclassAuthController{@ResourceprivateAuthServiceauthService;@PostMapping("/logout")@Operation(summary="登出系统")publicCommonResult<Boolean>logout(HttpServletRequestrequest){// 1. 从 Header 中剥离出 "Bearer xxxx" 后面的 Token 字符串Stringtoken=SecurityFrameworkUtils.obtainAuthorization(request,"Authorization","token");// 2. 只要 Token 不为空,就执行登出逻辑if(StrUtil.isNotBlank(token)){authService.logout(token,LoginLogTypeEnum.LOGOUT_SELF.getType());}returnsuccess(true);}}三、 核心逻辑 (Service 层)
这是登出业务的灵魂。我们需要在一个事务中完成三个动作。
@ServicepublicclassAuthServiceImplimplementsAuthService{@ResourceprivateOAuth2TokenServiceoauth2TokenService;@Overridepublicvoidlogout(Stringtoken,IntegerlogType){// 1. 核心动作:移除访问令牌和刷新令牌OAuth2AccessTokenDOaccessTokenDO=oauth2TokenService.removeAccessToken(token);// 2. (可选) 记录登出日志if(accessTokenDO!=null){createLoginLog(accessTokenDO.getUserId(),logType,LoginResultEnum.SUCCESS);}}}四、 底层实现 (TokenService) —— 关键步骤详解
这里我们展示oauth2TokenService.removeAccessToken的具体实现。
@ServicepublicclassOAuth2TokenServiceImplimplementsOAuth2TokenService{@ResourceprivateOAuth2AccessTokenMapperoauth2AccessTokenMapper;// 操作 MySQL (Access)@ResourceprivateOAuth2RefreshTokenMapperoauth2RefreshTokenMapper;// 操作 MySQL (Refresh)@ResourceprivateOAuth2AccessTokenRedisDAOoauth2AccessTokenRedisDAO;// 操作 Redis@Override@Transactional(rollbackFor=Exception.class)// ⚠️ 开启事务,保证原子性publicOAuth2AccessTokenDOremoveAccessToken(StringaccessToken){// 1. 先查库,确认 Token 存在OAuth2AccessTokenDOaccessTokenDO=oauth2AccessTokenMapper.selectByAccessToken(accessToken);if(accessTokenDO==null){returnnull;// 查无此人,直接返回}// 2. 删除 MySQL 中的 Access Token (清理垃圾数据)oauth2AccessTokenMapper.deleteById(accessTokenDO.getId());// 3. 【关键】删除 Redis 中的 Access Token// 这一步最重要!删了它,用户的请求会被立刻拦截 (401)oauth2AccessTokenRedisDAO.delete(accessToken);// 4. 【斩草】删除 MySQL 中的 Refresh Token// 这一步防止“诈尸”!防止前端利用 Refresh Token 自动续期oauth2RefreshTokenMapper.deleteByRefreshToken(accessTokenDO.getRefreshToken());returnaccessTokenDO;}}五、 思考:为什么是这个删除顺序?
你会发现代码里的顺序是:删 DB (Access) -> 删 Redis (Access) -> 删 DB (Refresh)。
Q: 为什么不先删 Redis?
在 Spring 的@Transactional事务机制下,不会对Redis回滚,如果先删了 Redis,但在后续删除 DB 时报错了(抛出异常):
- 事务回滚:DB 里的删除操作被撤销,数据恢复。
- Redis 无法回滚:Redis 数据已经没了。
- 结果:用户被踢下线了(因为 Redis 没了),但 DB 里留了两条垃圾数据。
Q: 现在的顺序有什么好处?
我们把 Redis 操作夹在 DB 操作中间或者放在最后。
如果删 Redis 报错抛出异常:
- 事务回滚:前面删 DB 的操作撤销。
- 结果:Redis 还在,DB 也在。用户登出失败,但数据是强一致的。
最佳实践总结:
对于“登出”这种操作,“删多了”(导致用户下线)永远比“删少了”(用户本该下线却没下线)要安全。所以无论哪种顺序,只要保证Redis 一定被删掉即可。
六、如果后端查询accesstoken时查询的redis,那么为什么mysql数据库中也要存储accesstoken表
只存 Redis 在技术上是行的,但在业务管理和系统健壮性上是不够的。
1. 复杂的运维管理需求(最主要原因)
Redis 是Key-Value数据库,它的查询能力非常弱。它只能通过 Key(Token)查 Value(用户信息)。
但后台管理员通常有以下需求,Redis 很难高效实现:
- “查看当前在线用户列表”:需要分页显示所有有效的 Token。Redis 的
KEYS *命令是性能杀手,生产环境禁止使用;使用SCAN又比较麻烦且难以排序。 - “查看某个用户登录了几个设备”:需要
SELECT * FROM token WHERE user_id = 1。Redis 要做这个查询,需要维护额外的 Set 索引,开发复杂度高。 - “强制踢某个用户下线”:管理员只知道用户的 ID(例如 1024),不知道他的 Token 是多少。如果只存 Redis,你很难通过 ID 反查出 Token Key 并将其删除。而在 MySQL 里,只需
SELECT access_token FROM table WHERE user_id = 1024,拿到 Token 后再去 Redis 删除即可。
结论:MySQL 是为了方便管理员“查账”和“管理”的。
2. 数据持久化与审计(Audit)
- Redis 是易失的:虽然 Redis 有持久化(RDB/AOF),但它本质上是缓存。如果 Redis 集群崩溃或者被误清空,所有用户的登录状态瞬间丢失,全部被迫下线。
- MySQL 是持久的:
- 审计追踪:安全审计可能需要查询“上周三中午 12 点,IP 为 1.2.3.4 的请求是哪个 Token 发起的?”。Redis 的 Token 过期就没了,只有 MySQL 这种关系型数据库适合做长期的日志保留(虽然 Access Token 短期会删,但 Refresh Token 或登录日志通常保留较久)。
3. 系统架构的“兜底”原则
在标准的架构设计中,缓存(Redis)应该永远只是数据库(MySQL)的一份子集或加速层。
- Source of Truth(单一真理来源):系统应该有一个地方存储了全量、最准确的数据,这个通常是 MySQL。
- 灾难恢复:极端情况下,如果 Redis 彻底挂了且无法恢复,虽然系统性能会降级,但如果我们有 MySQL 备份,理论上我们可以通过代码逻辑临时降级查 DB,或者利用 DB 数据快速预热恢复缓存(虽然 Access Token 这种短效数据通常不恢复,但架构一致性上通常保持双写)。