Redis 入门(三):Spring Boot 整合 Redis,RedisTemplate 实战与序列化乱码

个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <

文章目录

  • [Redis 入门(三):Spring Boot 整合 Redis,RedisTemplate 实战与序列化乱码](#Redis 入门(三):Spring Boot 整合 Redis,RedisTemplate 实战与序列化乱码)

Redis 入门(三):Spring Boot 整合 Redis,RedisTemplate 实战与序列化乱码

上一篇我们把五种数据类型和常用命令在 redis-cli 里敲了一遍,命令算是认识了。可回到真实项目里,没人会在 Java 代码里拼命令字符串,我们用的是 Spring Boot 提供的 RedisTemplate。这篇就解决一个问题:怎么把上一篇学到的命令,变成 Java 里一行行可维护的调用,顺便拆掉新手最容易踩的那颗雷------用 redis-cli 一看,key 前面多了一串乱码。

一、加依赖:starter 和连接池

Spring Boot 官方把 Redis 的客户端封装在了 spring-boot-starter-data-redis 里,它属于 Spring Data Redis 模块,对外统一暴露 RedisTemplate 这套 API。引入它的时候,spring-data-redis 和底层客户端会被一起带进来。

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- 想用连接池参数就必须加它,Lettuce 的连接池基于 commons-pool2 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

注意第二段依赖。很多人只在 yml 里写了 lettuce.pool.max-active,却忘了引 commons-pool2,结果配置静默失效,连接池根本没生效,还以为是池子参数没调好。这是个典型的"配置写了但没生效"的坑,排查时优先怀疑依赖。

还有一点需要理清:RedisTemplate 自己并不负责跟 Redis 通信,它只是一层统一的 API 门面,真正拿连接、发命令的是 RedisConnectionFactory,再往下才是具体的客户端实现。所以"用 Lettuce 还是 Jedis"这个问题,本质上换的是工厂和客户端,模板层的代码一行都不用改。想知道项目里现在到底用的是哪个,看一眼依赖树就清楚了:

bash 复制代码
mvn dependency:tree -Dincludes=io.lettuce:lettuce-core,redis.clients:jedis

Lettuce 和 Jedis 怎么选

Spring Boot 2.x 之后默认的客户端是 Lettuce,早期版本才是 Jedis。两者思路完全不同:

  • Jedis 走的是传统的阻塞式 TCP 连接,一个连接同一时刻只能被一个线程使用,所以必须靠连接池来撑并发,池子越大吞吐越高。
  • Lettuce 基于 Netty,底层是多路复用 + 异步非阻塞,一个连接可以被多个线程共享,不需要靠堆连接数来换性能。

如果看过压测对比,结论大致是:Jedis 的吞吐量数字更漂亮,但并发一上来就开始出现连接等待和超时;Lettuce 的响应时间更稳,基本稳定在毫秒级。还有个反直觉的点------Lettuce 调大连接池反而可能变慢,因为真正干活的连接数受 CPU 核数限制,连接多了只是徒增线程上下文切换,实践里把池子按 CPU 核数 + 1 来估就够用了。

想换回 Jedis 也很简单,把 Lettuce 排除掉,再单独引 Jedis:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>

二、写配置:application.yml

Spring Boot 3.x 的配置前缀是 spring.data.redis,2.x 是 spring.redis,别抄错版本,抄错了不会报错,只会默默连上 localhost:6379。

yaml 复制代码
spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password:            # 没设密码就留空
      database: 0          # 默认 0 号库,范围 0-15
      timeout: 3000ms      # 命令读写超时,不是连接超时
      lettuce:
        pool:
          max-active: 8    # 最大连接数,-1 表示不限制
          max-idle: 8      # 最大空闲连接
          min-idle: 0      # 最小空闲连接,想让池子常备连接就调大
          max-wait: 1000ms # 池子拿不到连接时的最长等待,-1 表示一直等

timeout 建议显式配。默认值在 Redis 卡住时会让请求线程挂很久,配一个 2~3 秒的值,至少能让故障快速暴露而不是拖垮整个线程池。另外 database 这个坑也值得记一笔:不同库之间数据隔离,程序连 0 号库,你在 redis-cli 里用默认的 0 号库才看得到数据,切换库用 select 1。

密码不建议直接写死在配置文件里。本地开发图省事可以填明文,上了服务器最好改成 ${REDIS_PASSWORD} 这类占位符,让 Spring 从环境变量或配置中心取值,免得密码跟着代码一起进 Git。同理,多个应用共用一个 Redis 实例时,用库号做隔离是最省事的办法,但要知道 Redis Cluster 只支持 0 号库,一旦将来上了集群,这套隔离就失效了,所以更可靠的隔离手段还是给 key 加业务前缀。

三、默认序列化器为什么会乱码

依赖和配置都有了,先按最直觉的写法试一下:

java 复制代码
@Autowired
private RedisTemplate redisTemplate;

public void saveUserName() {
    redisTemplate.opsForValue().set("user:1:name", "tom");
}

代码不报错,值也取得出来,但打开 redis-cli 一敲 keys *,看到的却是 \xac\xed\x00\x05t\x00\x0cuser:1:name 这种东西。原因在于 RedisTemplate 默认给 key 和 value 都套了 JdkSerializationRedisSerializer,它把 Java 对象用 JDK 自带的 ObjectOutputStream 序列化成二进制字节数组,再原样塞进 Redis。于是 redis-cli 里既看不懂 key,也没法用 GET 手工排查。

这个问题的本质不是"数据错了",而是"编码方式不通用":JDK 序列化是 Java 私有的二进制协议,只有 Java 自己反序列化得回来,别的语言、别的中间件一概不认识。它还有两个副作用,一是强制对象实现 Serializable 接口,不实现就抛 DefaultSerializer requires a Serializable payload 之类的异常;二是体积明显偏大,因为它会把完整的类名和字段描述一起写进去。所以你会看到那串乱码的开头是固定的 \xac\xed,那是序列化流的魔数,后面紧跟着就是类的全限定名。

生产项目里基本都会换掉它,换成 JSON。理由很实在:JSON 是跨语言的通用格式,Python 脚本、Go 服务、甚至你手工敲 redis-cli,都能读懂同一份数据;缓存里的数据还能直接被人肉排查,出问题时不用写额外的测试代码去 dump。代价是对象类型信息需要额外记录,这点下面会讲怎么处理。

四、配一个可读的 RedisTemplate

目标很明确:key 用纯字符串,value 用 JSON,这样 redis-cli 里人能读、跨语言能解析。做法是自定义一个 RedisTemplate Bean 覆盖掉自动配置的默认实现。

java 复制代码
package com.example.redisdemo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        StringRedisSerializer keySerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer valueSerializer =
                new GenericJackson2JsonRedisSerializer();

        // key 和 hashKey 用字符串,保证 redis-cli 里可读
        template.setKeySerializer(keySerializer);
        template.setHashKeySerializer(keySerializer);
        // value 和 hashValue 用 JSON
        template.setValueSerializer(valueSerializer);
        template.setHashValueSerializer(valueSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

四个序列化器要一起改,这是最容易漏的地方。只改了 key 不改 value,或者只改了 value 忘了 hashValue,都会让你在操作 Hash 类型时看到半截乱码。

GenericJackson2JsonRedisSerializer 比另一个常用类 Jackson2JsonRedisSerializer 更省事:后者需要在构造时指定具体类型,一个序列化器只能服务一种对象;前者在 JSON 里额外写入了 @class 字段来记录原始类型,反序列化时能自动还原成对应的对象,所以一个 Bean 就能通吃所有 value 类型。代价是 JSON 会多一个属性,且要求类有无参构造函数。如果你要存 LocalDateTime,可以自己构造 ObjectMapper 并注册 JavaTimeModule 后传给它的构造函数,否则时间字段会报错。

StringRedisTemplate 可以直接用

Spring Boot 还自动配置了一个 StringRedisTemplate,它的 key、value、hashKey、hashValue 全部使用 StringRedisSerializer,天生不存在乱码问题,@Autowired 进来就能用。所以有个简单的选型建议:只存字符串,直接用 StringRedisTemplate;要存对象,用上面自定义的 RedisTemplate。

但要记住一件事:这两个模板的序列化方式不同,不要用 A 写、用 B 读 。StringRedisTemplate 存进去的 "tom",用默认 JDK 序列化的模板去读是读不出字符串的,反之也一样。同一个 key 在项目里最好只由一种模板维护。

五、用模板操作五种数据类型

RedisTemplate 通过 opsForXxx() 拿到对应类型的操作对象,方法名和上一篇的命令基本能对上号。下面这些语句都写在 Service 的方法体里(redisTemplate 是注入进来的 RedisTemplate<String, Object>),不是独立的类,只看调用方式就行。

java 复制代码
// String:缓存、计数器、分布式锁
redisTemplate.opsForValue().set("user:1:name", "tom");
redisTemplate.opsForValue().set("sms:code:138", "9527", 5, TimeUnit.MINUTES);
Object name = redisTemplate.opsForValue().get("user:1:name");
redisTemplate.opsForValue().setIfAbsent("lock:order:1001", "1", 30, TimeUnit.SECONDS);
redisTemplate.opsForValue().increment("article:1:views", 1);

// Hash:存对象的多个字段,可以只改其中一个
redisTemplate.opsForHash().put("user:1", "name", "tom");
redisTemplate.opsForHash().put("user:1", "age", 18);
Map<Object, Object> user = redisTemplate.opsForHash().entries("user:1");
redisTemplate.opsForHash().hasKey("user:1", "name");
redisTemplate.opsForHash().delete("user:1", "age");

// List:有序可重复,常见于消息队列和最新列表
redisTemplate.opsForList().rightPush("queue:mail", "mail-1");
redisTemplate.opsForList().leftPushAll("timeline:1", "a", "b", "c");
List<Object> range = redisTemplate.opsForList().range("timeline:1", 0, -1);
Object first = redisTemplate.opsForList().leftPop("queue:mail");
redisTemplate.opsForList().trim("timeline:1", 0, 99); // 只留最新 100 条

// Set:无序去重,适合标签、点赞用户集合
redisTemplate.opsForSet().add("tag:1:users", "u1", "u2");
Set<Object> members = redisTemplate.opsForSet().members("tag:1:users");
redisTemplate.opsForSet().isMember("tag:1:users", "u1");
redisTemplate.opsForSet().remove("tag:1:users", "u2");

// ZSet:带分数,适合排行榜
redisTemplate.opsForZSet().add("rank:score", "tom", 95);
redisTemplate.opsForZSet().add("rank:score", "jerry", 88);
Set<Object> top10 = redisTemplate.opsForZSet().reverseRange("rank:score", 0, 9);
Long rank = redisTemplate.opsForZSet().rank("rank:score", "tom");
redisTemplate.opsForZSet().incrementScore("rank:score", "tom", 5);

通用的 key 级操作不挂在 opsForXxx() 上,直接调模板本身:

java 复制代码
redisTemplate.expire("user:1:name", 10, TimeUnit.MINUTES);
Long ttl = redisTemplate.getExpire("user:1:name", TimeUnit.SECONDS); // -1 永不过期,-2 key 不存在
Boolean exists = redisTemplate.hasKey("user:1:name");
redisTemplate.delete("user:1:name");
redisTemplate.delete(Arrays.asList("k1", "k2"));

有两点要留意。第一,opsForHash().put() 不会自动给 key 加过期时间,如果这个 Hash 是缓存,得自己补一次 expire,否则它会一直留在内存里。第二,opsForXxx() 每次调用都返回一个新的操作对象,但它很轻量,不涉及真实网络往返,所以没必要提前缓存到字段里,直接在方法里链式调用就好。

类型怎么选

命令记住了,落到代码里还是容易挑错类型,这里给几条能直接用的判断依据。

只存一个值,或者要做计数、加锁、存验证码,用 String。它是最简单也最省内存的一种,setIfAbsent 和 increment 两个方法顺手就能实现简易锁和计数器。

要存一个对象的多个字段,而且经常只改其中一两个,用 Hash。比如用户资料有昵称、头像、签名三个字段,用 Hash 的话改昵称只更新一个 field,不用把整个对象读出来再写回去,既省带宽也少了一次并发覆盖的风险。反过来,如果每次都是整体读写,字段也不多,其实直接用 String 存 JSON 更简单。

需要保持顺序、允许重复,用 List。典型场景是消息队列和"最新 N 条"列表,配合 trim 把长度卡住,内存就不会无限涨。

需要去重或者做集合运算,用 Set。比如给文章打标签、统计一天内的活跃用户,sadd 天然去重,交并差运算可以直接在 Redis 侧算完再返回。

需要按分数排序,用 ZSet。排行榜是它的经典用途,reverseRange 取前几名、rank 查某人排第几、incrementScore 改分数,都是 O(log N) 的操作,比在数据库里 order by 划算得多。

六、封装一个 RedisUtils

业务代码里到处写 redisTemplate.opsForValue().get(...) 有点啰嗦,而且 increment、getExpire 这类方法返回的是包装类型,可能为 null,直接拆箱会 NPE。包一层工具类,把强制转换和空值判断收进去,调用方会舒服很多。

java 复制代码
package com.example.redisdemo.util;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisUtils {

    private final RedisTemplate<String, Object> redisTemplate;

    public RedisUtils(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /** 写入并设置过期时间,单位由 unit 指定 */
    public void set(String key, Object value, long timeout, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, value, timeout, unit);
    }

    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public boolean delete(String key) {
        return Boolean.TRUE.equals(redisTemplate.delete(key));
    }

    public boolean hasKey(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    /** 返回剩余秒数:-1 表示永不过期,-2 表示 key 不存在 */
    public long getExpire(String key) {
        Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        return ttl == null ? -2L : ttl;
    }

    public boolean expire(String key, long timeout, TimeUnit unit) {
        return Boolean.TRUE.equals(redisTemplate.expire(key, timeout, unit));
    }

    /** 计数器自增,key 不存在时按 0 起算 */
    public long increment(String key, long delta) {
        Long value = redisTemplate.opsForValue().increment(key, delta);
        return value == null ? 0L : value;
    }

    /** 不存在才写入,天然适合做简易分布式锁 */
    public boolean setIfAbsent(String key, Object value, long timeout, TimeUnit unit) {
        return Boolean.TRUE.equals(
                redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit));
    }

    public void hSet(String key, String field, Object value) {
        redisTemplate.opsForHash().put(key, field, value);
    }

    public Object hGet(String key, String field) {
        return redisTemplate.opsForHash().get(key, field);
    }

    public void rightPush(String key, Object value) {
        redisTemplate.opsForList().rightPush(key, value);
    }
}

这个类不长,但有三处是刻意这么写的。

第一,方法返回值统一用基本类型而不是包装类型。redisTemplate.delete(key) 返回的是 Boolean,极端情况下可能为 null,直接 return redisTemplate.delete(key) 遇到自动拆箱就会抛 NPE。工具类是要被全项目调用的,这种地方必须自己兜住,外面用起来才不用每次都判空。

第二,用构造器注入而不是 @Autowired 挂字段。一是这样依赖关系写在签名上,谁依赖什么一目了然,也方便写单元测试;二是能顺带解决泛型问题,RedisTemplate<String, Object> 这个类型参数会完整地传到字段上。

第三,不要图省事把 redisTemplate 声明成 static 字段再用 @Autowired 注入。Spring 不会给静态字段注入依赖,结果就是每次调用都 NPE,这个坑几乎每个新手都踩过一次。真想要静态方法,正确做法是让工具类持有实例,或者在 @PostConstruct 里把注入的模板赋值给静态变量。

七、跑通一次 set/get 验证

最快的方式是写个单元测试,比启服务再发请求省事。

java 复制代码
package com.example.redisdemo;

import com.example.redisdemo.util.RedisUtils;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
class RedisTests {

    @Autowired
    private RedisUtils redisUtils;

    @Test
    void testSetAndGet() {
        redisUtils.set("user:1:name", "tom", 10, TimeUnit.MINUTES);
        assertEquals("tom", redisUtils.get("user:1:name"));
        System.out.println("剩余秒数: " + redisUtils.getExpire("user:1:name"));
    }
}

测试通过后,别急着关掉,去 redis-cli 里再确认一遍,这一步才是验证序列化配置有没有生效的关键:

bash 复制代码
127.0.0.1:6379> keys user:*
1) "user:1:name"
127.0.0.1:6379> get user:1:name
"tom"
127.0.0.1:6379> ttl user:1:name
(integer) 598

key 是干净的字符串、value 能原样读出来、TTL 正常递减,说明配置全对。如果存对象,get 会返回带 @class 的 JSON 字符串,比如 {"@class":"com.example.redisdemo.entity.User","id":1,"name":"tom"},这也是预期结果。

想用接口的形式验证,写个 Controller 调 RedisUtils 效果一样,注意别把 Redis 操作直接写在 Controller 里,那只适合做一次性连通性测试,真实代码还是应该放在 Service 层,让 Controller 保持薄薄一层。另外测试类如果连的是本机 Redis 还好,要是连的是团队共用环境,@SpringBootTest 里的写操作记得用独立的 key 前缀,别把别人的数据覆盖了。

八、踩坑清单

1. 注入泛型丢失。 直接写 @Autowired private RedisTemplate redisTemplate;(裸类型),或者用 @Resource 注入时按名字匹配到了别的 Bean,编译能过,但取出来的对象全被判成 Object,opsForValue() 的泛型也跟着丢,后续要到处强转,还可能出现 ClassCastException。要么给字段写全 RedisTemplate<String, Object>,要么像上面那样用构造器注入。

2. 半截乱码。 只配了 keySerializer 和 valueSerializer,忘了 hashKeySerializer 和 hashValueSerializer,结果 String 类型正常、Hash 类型乱码。四个一起配。

3. 两个模板混用。 StringRedisTemplate 和自定义的 RedisTemplate 序列化规则不同,数据互不可见。另外如果你用过 Spring Cache 的 @Cacheable,缓存 key 会自动带上 cacheName:: 前缀,看到形如 userCache::1 的 key 不要以为是乱码。

4. 连接池耗尽。 报 Could not get a resource from the pool 或 RedisCommandTimeoutException,通常是慢命令占着连接不放,或者 max-active 给小了。先排查有没有在生产环境跑 keys *、hgetall 大 key、smembers 大集合这类 O(N) 命令,再考虑加连接池参数。

5. Redis 挂了业务要不要降级。 缓存本质上是"加速层",不是"数据源"。查询类缓存建议 try-catch 兜底,Redis 不通就直接回源数据库,接口慢一点但还能用;而像分布式锁、验证码、幂等令牌这类依赖 Redis 保证正确性的场景,绝不能静默吞异常,必须快速失败,否则会出现锁没加上却以为加上了的严重后果。要不要降级,取决于这份数据丢了会不会算错业务结果,判断标准是业务正确性,不是"能不能不报错"。

6. 序列化器不要随便改。 今天的配置一旦上线,Redis 里就沉淀了大量历史数据。哪天你把 value 序列化器从 JSON 换成别的,或者换了 ObjectMapper 的配置,线上就会出现一批反序列化失败的异常------数据还在,只是读不出来了。所以这个配置要在项目早期定下来,中途要改就得配合数据迁移,或者干脆换个新 key 前缀灰度过渡。另外,GenericJackson2JsonRedisSerializer 依赖 JSON 里记录的类名来还原对象,这也意味着类名和包路径一旦调整,缓存里的老数据同样会反序列化失败,重构包结构时别忘了这一步。

7. key 的命名和 keys 命令。 建议用冒号分层,像 user:1:name、order:1001:status 这样,好处是 redis-cli 里能按前缀快速定位,也方便后续做统计和清理。但千万别用 keys user:* 去找------这个命令会阻塞整个 Redis 实例,生产环境敲一次就可能拖慢所有业务,要遍历请改用 scan:

bash 复制代码
127.0.0.1:6379> scan 0 match user:* count 100

需要提醒的是,keys() 在 RedisTemplate 上同样存在,写代码时一样要避免。

8. 大 key 和热 key 先避开。 一个 key 存了几兆的数据,或者几万个 member 的集合,读写时都会长时间占用连接,既容易触发超时,也会在集群迁移时成为麻烦。入门阶段的做法很简单:控制单个 value 的大小,集合类数据做好分片或者定期 trim,别让它无限长。

(至于缓存穿透、击穿、雪崩怎么防,那是本系列最后一篇的事,这里先记一笔。)

小结

这一篇从加依赖开始,一路走到自定义序列化器、五种类型的模板调用、工具类封装,最后用单元测试加 redis-cli 双重验证。最核心的一句话是:RedisTemplate 的问题几乎都和序列化有关,把四个序列化器配清楚,后面就顺了。

到这里,我们的数据已经能正常写进 Redis 并读出来。但还有一个问题没回答:这些数据到底存在哪儿?如果 Redis 进程被杀掉、机器断电重启,我们刚才写的 user:1:name 还在吗?下一篇就聊 RDB 和 AOF 这两种持久化方式,看看它们的原理、优缺点,以及生产环境到底该怎么配。

相关推荐
神秘的猪头1 小时前
Redis 数据结构实战指南:从缓存、点赞、排行榜到消息队列
redis
Wang's Blog3 小时前
Java框架 SpringCloud 快速入门: Nacos 服务多级存储模型
java·spring·spring cloud
Nebula_g4 小时前
JavaSE拓展:Arrays和Collections工具类
java·开发语言·算法·排序算法·工具类·javase·技术栈
新鲜势力呀4 小时前
PHP 消息队列实战:从同步接口阻塞到 RabbitMQ + Redis队列 + 异步任务架构完整优化方案
redis·rabbitmq·php
for_ever_love__5 小时前
Redis 持久化讲透:RDB、AOF 与混合持久化怎么选
java·数据库·redis·持久化·aof·区别·rdb
小码哥0685 小时前
能碳管理系统源码揭秘:避坑指南与实战选型
java·spring·能源·能源管理·能源监测·能碳管理
拾贰_C6 小时前
【English | conversation 】call | 抖音AI短句情景:打电话---come over
数据库·redis·缓存
鱼宵6 小时前
Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
人工智能·spring·microsoft·大模型·springai·chatclient
endswel6 小时前
Spring bean 注册多种方式
java·后端·spring