管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:登录与 Redis 会话实战

3674 字
18 分钟
多来买:登录与 Redis 会话实战

多来买:登录与 Redis 会话实战#

核心问题:用户名密码怎样建立 Redis 有状态会话,Token、TTL 与退出语义分别是什么
事实来源:user-service、user-api、common-util 当前源码
证据口径:代码已确认;未构建、未启动、未连接数据库或 Redis

本文只聚焦会话建立和生命周期。Gateway 如何验证并向下游透传身份,见同专题的《Gateway 鉴权与身份透传实战》;可信边界与演进建议,见《鉴权安全边界与演进》。

当前实现可以概括为:

name + passwd
-> MD5(passwd)
-> 查询 user_info
-> 雪花 ID 生成 Token
-> Redis 保存 userId + 登录 IP
-> 固定 TTL 2 小时
-> logout 删除当前 Token Key

1. 为什么它是 Redis Session,而不是 JWT#

1.1 当前 Token 本身包含什么#

当前 Token 来自雪花 ID:

Long snowflakeId = hutoolIdUtil.getSnowflakeId();
String token = String.valueOf(snowflakeId);

代码出处:

  • 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/controller/UserController.java
  • 类与方法:UserController#login

这个字符串没有三段式 JWT 结构,没有 Header、Payload、Signature,也没有由 Gateway 在本地验签。

Gateway 必须用 Token 拼出 Redis Key,并读取服务端状态后才能知道用户是谁。

1.2 Redis Session 与 JWT 的判断标准#

判断维度当前多来买实现自包含 JWT 的常见实现
用户身份放在哪里Redis valueJWT Claims
Gateway 是否每次查 Redis基础验签通常不需要
是否有签名当前没有
主动退出删除 Redis Key 即可通常需要黑名单或额外会话状态
Token 被删除后是否立即失效单纯 JWT 通常不会
Redis 故障影响无法读取会话只做本地验签时影响较小
服务端是否有状态可以接近无状态
当前 Token 是否适合携带敏感数据不携带数据,只做索引Payload 可解码,也不应放敏感数据

所以面试时应准确表述为:

项目采用 Redis 有状态会话。Token 是雪花 ID 字符串,Gateway 使用它查询 Redis 中的 userId 与登录 IP,而不是解析 JWT。

2. 接口契约#

2.1 服务内路径与网关外部路径#

当前 Controller 明确声明的服务内路径是:

动作HTTP 方法user-service Controller 路径
登录POST/user/login
退出GET/user/logout

仓库内只看到 Gateway 从 Nacos 导入 server-gateway-dev.yaml 和 common.yaml,没有看到这些外部配置的实际内容。

因此,下面两个结论必须分开:

  • 代码已确认:用户服务 Controller 匹配 /user/login 与 /user/logout。
  • 未验证:客户端经过 Gateway 时最终使用的外部路径、路由前缀和 StripPrefix 规则。

本文的 HTTP 示例使用 Controller 路径表达接口结构,不把未读取到的 Gateway 路由写成既定事实。

2.2 登录请求体#

请求对象是 UserInfoParam。

源码节选:

  • 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/query/UserInfoParam.java
  • 类:UserInfoParam
@Data
public class UserInfoParam {
private Long id;
private String loginName;
private String nickName;
private String passwd;
private String name;
private String phoneNum;
private String email;
private String headImg;
private String userLevel;
}

登录实现实际只读取两个字段:

  • name:作为用户名查询条件。
  • passwd:先执行 MD5,再作为密码摘要查询条件。

其余字段即使出现在请求体中,也没有被 UserServiceImpl#login 使用。

这说明当前 DTO 是一个复用范围较宽的用户参数对象,而不是只包含登录字段的专用 DTO。

2.3 登录分支真值表#

请求体或数据库情况当前代码路径结果
body 为 nullService 读取 userInfoParam.getName可能抛出空指针,由全局异常处理接管
passwd 为 null调用 getBytes可能抛出空指针
name 为空字符串、passwd 有值仍计算 MD5 并按 name + passwd 查询通常查不到并返回登录失败,实际数据未验证
name 有值、passwd 为空字符串计算空字符串的 MD5 并查询通常查不到并返回登录失败,实际数据未验证
name 或密码错误selectOne 返回 null返回 USER_LOGIN_CHECK_FAIL
name 与 MD5(passwd) 匹配唯一用户selectOne 返回 UserInfo写 Redis 会话并返回 UserLoginDTO
查询结果超过一条selectOne 期待单条可能抛出多结果异常
Redis 写入异常bucket.set 未完成异常向上抛出,不会返回成功 DTO

这里没有 Bean Validation、@Valid 或显式的空值校验。

“body 为 null”和“passwd 为 null”的结果是根据当前方法直接解引用得出的候选风险,本轮没有发送请求复现。

2.4 退出契约#

当前退出接口:

  • 使用 GET /user/logout。
  • 只从 Header 读取 token。
  • 用 token 拼出 Redis Key。
  • 删除对应 RBucket。
  • 无论 delete 返回什么,都返回 Result.ok。

它不会读取 Cookie 中的 token,这一点与 Gateway 的 Token 提取策略不对称。

3. 端到端时序#

3.1 登录并建立 Redis 会话#

sequenceDiagram participant C as "客户端" participant G as "Gateway" participant UC as "UserController" participant US as "UserServiceImpl" participant DB as "MySQL user_info" participant R as "Redis" C->>G: "POST 登录请求" Note over G: "登录路径是否公开由 authUrls.url 决定" G->>UC: "路由后的 /user/login" UC->>UC: "IpUtil 获取客户端 IP" UC->>UC: "雪花算法生成 Token" UC->>US: "login(UserInfoParam, ip, token)" US->>US: "MD5(passwd)" US->>DB: "按 name + passwd 摘要查询" alt "没有唯一匹配用户" DB-->>US: "null 或查询异常" US-->>UC: "null 或异常" UC-->>C: "业务失败响应" else "唯一匹配用户" DB-->>US: "UserInfo" US->>R: "SET user:login:{token}, value, TTL 2h" US-->>UC: "UserLoginDTO" UC-->>C: "Result.ok" end

这个时序中有一个容易忽略的事实:

Token 在查询数据库之前就已经生成。登录失败会消耗一个雪花 ID,但不会写入 Redis。

4. 用户表与查询模型#

登录查询使用 UserInfo 映射的 user_info 表,当前条件字段是 namepasswd。实体还继承了主键、创建时间、更新时间和逻辑删除字段;仓库没有数据库迁移脚本,因此用户名唯一约束属于未验证信息。

4.1 查询等价语义#

UserInfoMapper 继承 MyBatis-Plus BaseMapper,没有自定义登录 SQL。

当前 Wrapper:

LambdaQueryWrapper<UserInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(UserInfo::getName, userName);
wrapper.eq(UserInfo::getPasswd, password);
UserInfo userInfo = userInfoMapper.selectOne(wrapper);

从业务语义看,它等价于:

SELECT <UserInfo 映射字段>
FROM user_info
WHERE name = ?
AND passwd = ?
AND <MyBatis-Plus 逻辑删除条件>;
随后由 selectOne 要求映射结果最多一条。

这里是查询语义伪代码,不是仓库中的 Mapper XML,也不假定具体数据库方言会自动追加 LIMIT。

selectOne 的契约意味着“相同 name + passwd 最多匹配一条记录”。仓库没有数据库迁移脚本,因此本轮不能确认 name 是否有唯一索引。

4.2 为什么同时按用户名和摘要查询#

当前实现不是:

  1. 先按用户名查用户;
  2. 再在 Java 中比较密码。

而是把用户名和密码摘要都放进 SQL 条件。

这种写法的直接结果是:

  • 用户不存在和密码错误都得到 null。
  • Java 层不会拿到错误账号的 UserInfo。
  • 无法在当前方法里进一步判断账号状态或失败次数。
  • 如果以后更换 BCrypt 这类带随机盐的慢哈希,通常要改成先查用户再调用 passwordEncoder.matches。

5. Controller:HTTP 适配、IP 与 Token#

5.1 登录 Controller 的真实代码#

源码节选:

  • 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/controller/UserController.java
  • 类与方法:UserController#login
@PostMapping("/user/login")
public Result login(@RequestBody UserInfoParam userInfo,
HttpServletRequest request) {
String ip = IpUtil.getIpAddress(request);
Long snowflakeId = hutoolIdUtil.getSnowflakeId();
String token = String.valueOf(snowflakeId);
UserLoginDTO userLoginDTO =
userService.login(userInfo, ip, token);
if (userLoginDTO == null) {
return Result.build(
"登录失败",
UserCodeEnum.USER_LOGIN_CHECK_FAIL
);
}
return Result.ok(userLoginDTO);
}

Controller 依次做了四件事:

  1. 从 Servlet 请求提取 IP。
  2. 通过 HutoolIdUtil 生成雪花 ID。
  3. 把请求参数、IP 和 Token 一起交给 Service。
  4. 把 null 转成登录失败,否则返回统一成功响应。

密码校验和 Redis 写入没有放进 Controller,职责划分是清晰的。

5.2 雪花 ID 从哪里来#

源码节选:

  • 路径:duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/util/HutoolIdUtil.java
  • 类:HutoolIdUtil
@Component
@ConfigurationProperties(prefix = "hutool.snowflake")
public class HutoolIdUtil {
private long workerId;
private long datacenterId;
private Snowflake snowflake;
@PostConstruct
private void init() {
snowflake = new Snowflake(workerId, datacenterId);
}
public Long getSnowflakeId() {
return snowflake.nextId();
}
}

外部配置负责提供 workerId 和 datacenterId 的语义值。

本文不记录具体配置值。

雪花 ID 的优点是生成快、通常唯一、无需访问数据库;但“唯一 ID”不等于“安全令牌”:

  • ID 有时间和节点结构。
  • 它的目标是唯一性,不是不可预测性。
  • 如果多个实例错误地复用相同 workerId、datacenterId,可能破坏唯一性假设。
  • 如果 Token 碰撞,同一个 Redis Key 会被后一次登录覆盖。

当前没有运行或多实例配置证据,因此这些属于设计边界,不表示已发生碰撞。

6. Service:校验账号密码#

6.1 Service 接口#

UserService 对外暴露:

UserLoginDTO login(
UserInfoParam userInfo,
String ip,
String token
);
void logout(HttpServletRequest request);

Token 由 Controller 生成后传入,Service 不负责生成 Token。

6.2 密码摘要与数据库查询#

源码节选:

  • 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/service/impl/UserServiceImpl.java
  • 类与方法:UserServiceImpl#login
String userName = userInfoParam.getName();
String password = DigestUtils.md5DigestAsHex(
userInfoParam.getPasswd().getBytes()
);
LambdaQueryWrapper<UserInfo> lambdaQueryWrapper =
new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(UserInfo::getName, userName);
lambdaQueryWrapper.eq(UserInfo::getPasswd, password);
UserInfo userInfo =
userInfoMapper.selectOne(lambdaQueryWrapper);
if (userInfo == null) {
return null;
}

当前摘要公式可以写成:

database.passwd == MD5(request.passwd bytes)

代码中没有:

  • 独立随机盐;
  • BCrypt、Argon2 或 PBKDF2;
  • 账号状态检查;
  • 登录失败次数;
  • 验证码;
  • 登录限流;
  • 多因素认证。

另外,getBytes 没有显式指定字符集。对于普通 ASCII 密码通常结果一致;对非 ASCII 输入,摘要可能受 JVM 默认字符集影响。

6.3 为什么无盐 MD5 不适合生产密码#

MD5 的问题不只是存在碰撞,更重要的是它计算非常快。

对于密码存储,计算快意味着攻击者获得摘要后可以高速尝试大量候选密码。

生产系统通常选择:

  • BCrypt;
  • Argon2id;
  • PBKDF2;
  • 配合每个用户独立随机盐和合理成本参数。

同时应配合 HTTPS、登录限流、账号锁定策略、安全审计和敏感日志控制。

这些是安全演进建议,不是当前项目已经实现的能力。

7. Service:建立 Redis 会话#

7.1 Redis 中保存的 DTO#

源码:

  • 路径:duolaimall-user/user-api/src/main/java/com/cskaoyan/mall/user/dto/UserLoginInfoDTO.java
  • 类:UserLoginInfoDTO
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginInfoDTO {
String userId;
String ip;
}

Redis 中不保存完整 UserInfo。

这避免了每次鉴权都携带用户昵称、手机号、密码摘要等无关字段,也降低了会话对象与用户表字段变化的耦合。

7.2 Key 规则#

源码:

  • 路径:duolaimall-user/user-api/src/main/java/com/cskaoyan/mall/user/consts/UserConstants.java
  • 类:UserConstants
public static final String USER_LOGIN_KEY_PREFIX =
"user:login:";
public static final int USERKEY_TIMEOUT =
60 * 60 * 24 * 7;
public static final String USER_LOGIN_TOKEN_HEADER =
"token";

真实登录代码只使用 USER_LOGIN_KEY_PREFIX。

Key 结构:

user:login:{token}

概念示例:

key = user:login:<雪花 ID 字符串>
value = {
"userId": "<数据库用户 ID>",
"ip": "<登录时提取的 IP>"
}
ttl = 2 hours

示例使用占位符,不包含真实 Token、用户 ID 或网络地址。

7.3 写入会话的真实代码#

源码节选:

  • 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/service/impl/UserServiceImpl.java
  • 类与方法:UserServiceImpl#login
UserLoginInfoDTO userLoginInfoDTO =
new UserLoginInfoDTO();
userLoginInfoDTO.setUserId(
userInfo.getId().toString()
);
userLoginInfoDTO.setIp(ip);
String key =
UserConstants.USER_LOGIN_KEY_PREFIX + token;
RBucket<Object> bucket =
redissonClient.getBucket(key);
bucket.set(
userLoginInfoDTO,
2,
TimeUnit.HOURS
);

这段代码同时表达了三件事:

  1. Redis Key 由固定前缀和 Token 拼接。
  2. Value 只含 userId 与 IP。
  3. 实际 TTL 是两小时。

USERKEY_TIMEOUT 的七天常量没有传给 bucket.set,因此不能把当前登录有效期写成七天。

7.4 序列化方式#

common-util 的 RedissonConfig 使用 JsonJacksonCodec。

这意味着会话 DTO 以 JSON 语义序列化,而不是 Java 原生序列化。

当前写入端声明 RBucket,读取端声明 RBucket。Java 泛型不会写进 Redis Key,本质仍由相同 RedissonClient 与 Codec 解释数据。

长期维护时需要关注:

  • DTO 包名或字段发生不兼容变化;
  • 不同服务加载了不一致的 Codec;
  • 旧会话与新 DTO 版本是否能兼容。

当前 user-service 和 gateway 都扫描公共 Redisson 配置,但本轮没有连接 Redis 验证真实序列化内容。

7.5 TTL 是否续期#

Gateway 读取会话使用 bucket.get,没有调用 expire、remainTimeToLive 或 touch。

因此当前代码表现为固定两小时过期:

登录时刻 T0
└─ 写入 TTL = 2h
T0 + 30min
└─ Gateway 读取,不续期
T0 + 1h59min
└─ Gateway 读取,不续期
约 T0 + 2h
└─ Key 到期,后续请求视为未登录

这不是滑动会话。

用户持续操作不会从代码层自动把有效期恢复为两小时。

7.6 多端和并发登录语义#

当前 Key 按 Token 组织,而不是按 userId 组织。

同一用户并发登录时:

  • 每次生成不同 Token;
  • 每个 Token 对应独立 Redis Key;
  • 旧 Token 不会被新登录主动删除;
  • 每个会话有自己的登录 IP 与剩余 TTL;
  • 退出只删除当前传入的一个 Token。

因此当前更接近“允许同一用户同时保留多个会话”,而不是单点登录或后登录踢前登录。

8. 退出登录#

源码节选:

  • 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/service/impl/UserServiceImpl.java
  • 类与方法:UserServiceImpl#logout
String token = request.getHeader("token");
String key =
UserConstants.USER_LOGIN_KEY_PREFIX + token;
redissonClient.getBucket(key).delete();

Controller 随后固定返回 Result.ok。

8.1 退出的状态变化#

stateDiagram-v2 [*] --> Active: "登录成功,写 Redis Key" Active --> Expired: "固定 TTL 到期" Active --> Deleted: "logout 删除当前 Token Key" Active --> Active: "Gateway 读取,不续期" Expired --> Anonymous: "Gateway 查不到会话" Deleted --> Anonymous: "Gateway 查不到会话"

Gateway 查找 Token 时:

  1. Header token 优先;
  2. Header 为空才读取 Cookie token。

logout 只读取 Header。

因此:

客户端携带方式Gateway 能否认证logout 能否删除对应会话
仅 Header token
仅 Cookie token当前代码不能按 Cookie 值删除
Header 与 Cookie 都有使用 Header删除 Header 对应会话
两者都没有视为未登录尝试删除 user:login,然后仍返回成功

最后一行是静态拼接逻辑,未在 Redis 中实际验证。

8.3 GET 退出的语义#

退出会改变服务端会话状态,但接口使用 GET。

从 HTTP 语义看,具有状态变化的操作通常更适合 POST 或 DELETE。

如果未来完全依赖 Cookie 认证,还要结合 SameSite、CSRF 防护等策略审视退出接口。

这属于演进建议,不是本轮代码修改。

9. 短复习点#

  1. 当前 Token 是雪花 ID 字符串,只是 Redis 会话索引,不是 JWT。
  2. Redis Key 是 user:login:{token},Value 只保存字符串 userId 和登录 IP。
  3. 实际 TTL 来自 bucket.set(..., 2, TimeUnit.HOURS),是固定两小时,不是常量中的七天。
  4. 登录查询使用 name + MD5(passwd);无盐 MD5、缺少显式输入校验属于代码可见边界。
  5. logout 只删除 Header token 对应的一个 Key,不是全端退出,也不支持仅 Cookie token 的退出。

10. 关键源码导航#

阅读顺序文件关键位置作用
1duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/controller/UserController.javalogin获取 IP、生成 Token、包装响应
2duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/service/impl/UserServiceImpl.javalogin密码摘要、查库、写入 Redis
3同上logout删除当前 Token 会话
4duolaimall-user/user-api/src/main/java/com/cskaoyan/mall/user/dto/UserLoginInfoDTO.javaDTORedis 会话值
5duolaimall-user/user-api/src/main/java/com/cskaoyan/mall/user/consts/UserConstants.java登录常量Key 前缀与 Header 名
6duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/util/HutoolIdUtil.javagetSnowflakeIdToken ID 来源
7duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/config/RedissonConfig.javaCodecRedis JSON 序列化配置

11. 本章总结#

本篇保留的是当前源码事实,不把密码升级、随机 Token、滑动续期或单点登录写成已实现能力。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:登录与 Redis 会话实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-login-redis-session/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录