原创

Spring Boot 接入 Redis Session 实战

为什么要把 Session 放到 Redis

单机应用里,HttpSession 默认存在应用进程内。用户登录后,请求只要一直打到这台机器,基本没什么问题。

问题从多实例部署开始:

  • 用户第一次请求落到 A 实例,Session 存在 A 的内存里;
  • 下一次请求被负载均衡打到 B 实例;
  • B 实例找不到 Session,于是用户像是“突然掉线”了。

当然,可以用 Nginx 的 ip_hash 或者负载均衡的粘性会话把同一个用户固定到一台机器上。但这只是绕开问题,不是解决问题。实例扩缩容、机器重启、灰度发布时,Session 仍然会变成一个麻烦点。

把 Session 交给 Redis,本质上是把“用户状态”从应用进程里拆出去。应用实例变成无状态,任意一台机器都可以处理同一个用户的请求。

这才是 Spring Boot 接入 Redis Session 最主要的价值。

Spring Session 做了什么

Spring Boot 接 Redis Session 通常不是自己手写一套 HttpSession 读写 Redis 的逻辑,而是使用 Spring Session。

Spring Session 会在 Servlet Filter 层接管原生 HttpSession,把 Session 的创建、读取、更新、过期交给 Redis 存储。业务代码里依然可以继续这样写:

request.getSession().setAttribute("userId", userId);

或者:

@GetMapping("/me")
public Object me(HttpSession session) {
    return session.getAttribute("userId");
}

对业务代码来说,它仍然像是在使用普通 HttpSession。差别在底层:数据不再放在当前 JVM 内存里,而是写到了 Redis。

Spring Boot 对 Spring Session 有自动配置支持。Servlet Web 应用使用 Redis 作为 Session 存储时,可以通过 spring-boot-starter-session-data-redis 自动配置;官方文档也明确说明,Servlet 场景下自动配置会替代手动使用 @Enable*HttpSession 的需求。([Home][1])

引入依赖

以 Spring Boot 3.x 为例,Maven 可以这样引入:

<dependencies>
    <!-- Web 应用基础依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Redis 客户端与 Spring Data Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!-- Spring Session Redis 支持 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-session-data-redis</artifactId>
    </dependency>
</dependencies>

如果项目没有使用 Spring Boot starter,也可以直接引入:

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

不过在 Spring Boot 项目里,优先使用 starter 更省心,版本也由 Spring Boot 统一管理。

配置 Redis 和 Session

常见的 application.yml 配置如下:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password:
      database: 0
      timeout: 3000ms

  session:
    store-type: redis
    timeout: 30m
    redis:
      namespace: spring:session:demo
      flush-mode: on_save
      repository-type: default

server:
  servlet:
    session:
      cookie:
        name: DEMO_SESSION
        http-only: true
        secure: false
        same-site: lax

几个配置值得单独说一下。

spring.session.timeout

这是 Session 的过期时间。比如:

spring:
  session:
    timeout: 30m

表示用户 30 分钟没有访问,Session 会过期。

Spring Boot 官方文档说明,Session 超时时间可以使用 spring.session.timeout 配置;如果 Servlet Web 应用没有设置这个属性,会回退使用 server.servlet.session.timeout。([Home][1])

spring.session.redis.namespace

建议每个应用都显式配置 namespace:

spring:
  session:
    redis:
      namespace: spring:session:order-service

默认 namespace 是 spring:session。如果多个应用共用一个 Redis,又都使用默认 namespace,后期排查会很难受。轻则 key 混在一起不好看,重则不同应用之间的 Session 结构互相影响。

Spring Session 官方文档也建议在多个应用使用同一个 Redis 实例时,通过 namespace 隔离 Session key。([Home][2])

flush-mode

spring:
  session:
    redis:
      flush-mode: on_save

常见值有:

  • on_save:请求结束时统一保存 Session;
  • immediate:Session 一发生变更就立即写入 Redis。

大部分业务用 on_save 就够了。immediate 更及时,但 Redis 写入次数也会更多。除非你明确知道自己需要这个语义,否则不要一上来就改成 immediate

写一个简单的登录示例

先写一个登录接口,把用户信息放进 Session:

@RestController
@RequestMapping("/api")
public class LoginController {

    @PostMapping("/login")
    public Map<String, Object> login(HttpSession session,
                                     @RequestParam String username) {
        session.setAttribute("loginUser", username);

        return Map.of(
                "success", true,
                "sessionId", session.getId(),
                "username", username
        );
    }

    @GetMapping("/profile")
    public Map<String, Object> profile(HttpSession session) {
        Object loginUser = session.getAttribute("loginUser");

        if (loginUser == null) {
            return Map.of(
                    "loggedIn", false,
                    "message", "未登录"
            );
        }

        return Map.of(
                "loggedIn", true,
                "username", loginUser,
                "sessionId", session.getId()
        );
    }

    @PostMapping("/logout")
    public Map<String, Object> logout(HttpSession session) {
        session.invalidate();

        return Map.of(
                "success", true,
                "message", "已退出登录"
        );
    }
}

启动 Redis 和 Spring Boot 应用后,调用登录接口:

curl -i -X POST "http://localhost:8080/api/login?username=zhangsan"

响应头里会看到类似:

Set-Cookie: DEMO_SESSION=xxxxxx; Path=/; HttpOnly; SameSite=Lax

再带着 Cookie 访问:

curl -i "http://localhost:8080/api/profile" \
  -H "Cookie: DEMO_SESSION=xxxxxx"

如果能正常返回用户信息,说明 Spring Session 已经接管了 Session。

此时可以在 Redis 里看一下 key:

redis-cli keys "spring:session:demo*"

一般会看到类似这样的结构:

spring:session:demo:sessions:xxxxxx
spring:session:demo:sessions:expires:xxxxxx
spring:session:demo:expirations:171xxxxxxx

实际 key 结构会随 Spring Session 版本和 repository 类型有所差异,不建议业务代码依赖这些内部 key。排查问题时可以看,业务逻辑里不要直接操作它。

JSON 序列化:一个很容易被忽略的坑

Spring Session 默认会序列化 Session attribute。很多项目一开始直接把用户对象塞进 Session:

session.setAttribute("loginUser", user);

短期看没问题,后面容易踩坑:

  • 类结构变了,旧 Session 反序列化失败;
  • 多个服务共享 Session,但实体类版本不一致;
  • Session 里放了不该序列化的对象;
  • Redis 里的值不可读,排查不方便。

如果 Session 里只是保存用户 ID、用户名、权限摘要,问题会少很多。

推荐做法是:Session 里放小对象、稳定字段,不要把完整 UserEntity、ORM 懒加载对象、复杂上下文一股脑塞进去。

如果确实要调整序列化方式,可以注册名为 springSessionDefaultRedisSerializer 的 Bean。Spring Session 官方文档说明,该 Bean 名称用于覆盖默认 Redis Session 序列化器,并给出了使用 JSON 序列化的配置方式。([Home][2])

示例:

@Configuration
public class SessionConfig {

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer(ObjectMapper objectMapper) {
        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }
}

但这里不要误会:换成 JSON 不是万能药。它只是让数据更可读、跨版本时更可控。真正重要的是别把 Session 当成垃圾桶。

defaultindexed 怎么选

Spring Session Redis 有两类常见 repository:

spring:
  session:
    redis:
      repository-type: default

以及:

spring:
  session:
    redis:
      repository-type: indexed

简单判断:

  • 普通登录态保存,用 default
  • 需要按用户查找所有 Session,比如“查看当前账号所有登录设备”“踢掉某个用户的全部 Session”,考虑 indexed

Spring Session 官方文档说明,RedisSessionRepository 是基础实现,不做额外索引;RedisIndexedSessionRepository 支持索引能力,可以根据用户等条件查找 Session。([Home][2])

如果使用 indexed,可以通过 FindByIndexNameSessionRepository 查某个用户的 Session:

@Service
public class UserSessionService {

    private final FindByIndexNameSessionRepository<? extends Session> sessionRepository;

    public UserSessionService(FindByIndexNameSessionRepository<? extends Session> sessionRepository) {
        this.sessionRepository = sessionRepository;
    }

    public Collection<? extends Session> findUserSessions(String username) {
        return sessionRepository.findByPrincipalName(username).values();
    }

    public void deleteSession(String sessionId) {
        sessionRepository.deleteById(sessionId);
    }
}

不过 indexed 不是白来的。它会维护额外索引,数据结构更复杂。官方文档也提醒,RedisIndexedSessionRepository 在 Redis Cluster 场景下订阅事件时存在限制,可能导致某些索引无法及时清理。([Home][2])

所以不要为了“看起来更强”就开 indexed。用不到按用户查 Session,就别加这层复杂度。

Cookie 配置不能随便抄

Session 存 Redis,不代表 Cookie 就不重要了。

浏览器和服务端之间仍然靠 Cookie 里的 Session ID 关联用户。Redis 只是存了 Session 数据,Cookie 丢了、跨域没带上、SameSite 配错,服务端一样找不到用户状态。

常见配置:

server:
  servlet:
    session:
      cookie:
        name: DEMO_SESSION
        http-only: true
        secure: true
        same-site: none

几个点要注意:

前后端同域

如果前端和后端同域,比如:

https://example.com
https://example.com/api

通常用:

same-site: lax
secure: true

就比较合适。

前后端跨域

如果前端是:

https://www.example.com

后端是:

https://api.example.com

或者完全不同域名,就要同时处理:

  • 后端 CORS 允许携带凭证;
  • 前端请求开启 credentials
  • Cookie 设置 SameSite=None; Secure
  • HTTPS 环境。

前端示例:

fetch("https://api.example.com/api/profile", {
  method: "GET",
  credentials: "include"
});

后端 CORS 示例:

@Configuration
public class CorsConfig {

    @Bean
    public WebMvcConfigurer webMvcConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/api/**")
                        .allowedOrigins("https://www.example.com")
                        .allowedMethods("GET", "POST", "PUT", "DELETE")
                        .allowCredentials(true);
            }
        };
    }
}

这里最常见的错误是:后端 Session 配好了,Redis 也有数据,但浏览器请求根本没把 Cookie 带回来。然后开发者开始怀疑 Spring Session,其实问题在 Cookie 和跨域。

生产环境还要看这些问题

1. Redis 不能裸奔

Session 是登录态。Redis 出问题,用户登录态就会大面积异常。

生产环境至少要考虑:

  • Redis 主从或集群;
  • 连接池配置;
  • 密码和网络隔离;
  • 慢查询和内存监控;
  • key 淘汰策略;
  • Redis 故障时的降级预期。

尤其是 maxmemory-policy,不要随便使用会淘汰 Session key 的策略。Redis 内存打满后,如果 Session 被淘汰,表现出来就是用户随机掉线。

2. Session 不要存太大

Redis 很快,但不是让你把所有用户上下文都塞进去。

Session 里建议放:

userId
username
roleCodes
tenantId
loginTime

不建议放:

完整用户实体
菜单树
权限大对象
购物车大列表
上传文件临时内容
第三方接口返回的大 JSON

Session 越大,请求结束时写 Redis 的成本越高,网络传输和序列化开销也越明显。

很多 Session 性能问题,不是 Redis 慢,是你放进去的东西太重。

3. 不要混用 Token 和 Session 逻辑

有些项目一边接 Redis Session,一边又自己发 JWT 或自定义 Token。结果登录态有两套来源:

  • Cookie 里有 Session ID;
  • Header 里有 Authorization;
  • Redis 里又存用户状态。

这不是不行,但要设计清楚。否则排查登录问题时会很混乱:到底是 Session 过期、Token 过期,还是 Redis 里的用户状态被删了?

如果是传统后台管理系统,Redis Session 很合适。

如果是开放 API、移动端、多端认证,可能 Token 体系更自然。不要因为 Redis Session 配起来简单,就把它硬套到所有认证场景。

一个推荐的最小配置

普通后台系统可以从这个配置开始:

spring:
  data:
    redis:
      host: redis.example.com
      port: 6379
      password: your-password
      database: 0
      timeout: 3000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
          min-idle: 2
          max-wait: 1000ms

  session:
    store-type: redis
    timeout: 30m
    redis:
      namespace: spring:session:admin
      flush-mode: on_save
      repository-type: default

server:
  servlet:
    session:
      cookie:
        name: ADMIN_SESSION
        http-only: true
        secure: true
        same-site: lax

再配一个序列化器:

@Configuration
public class SessionRedisConfig {

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer(ObjectMapper objectMapper) {
        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }
}

业务代码里只存轻量字段:

public record LoginUserSession(
        Long userId,
        String username,
        List<String> roles
) implements Serializable {
}

登录时:

session.setAttribute("loginUser", new LoginUserSession(
        user.getId(),
        user.getUsername(),
        roleCodes
));

读取时:

LoginUserSession loginUser =
        (LoginUserSession) session.getAttribute("loginUser");

这套方案不花哨,但足够稳。真正上线后,稳定性往往比“功能全”更重要。

小结

Spring Boot 接入 Redis Session 的核心并不复杂:引入 Spring Session Redis 依赖,配置 Redis,设置 Session 超时时间和 namespace,业务代码继续使用 HttpSession

真正需要认真处理的是后面的工程细节:

  • Session 放 Redis 是为了解决多实例共享登录态;
  • namespace 必须按应用隔离;
  • Session attribute 不要塞大对象;
  • JSON 序列化可以提升可读性和兼容性,但不能替代良好的 Session 设计;
  • 普通场景用 default repository,需要按用户查 Session 再考虑 indexed
  • 跨域场景要重点检查 Cookie、CORS、SameSite 和 HTTPS;
  • Redis 是登录态基础设施,生产环境必须按核心组件对待。

Spring Session 把接入门槛降得很低,但它没有替你设计登录态边界。边界不清楚,Redis 只是把原来藏在 JVM 里的问题搬到了另一处。

参考资料

  • Spring Boot Reference Documentation:Spring Session 自动配置、Redis Session starter、Session timeout 配置。([Home][1])
  • Spring Session Reference Documentation:Redis namespace、JSON 序列化、repository type、indexed repository 说明。([Home][2])
正文到此结束
评论插件初始化中...
Loading...