原创

Spring Boot 使用 Redis 的实战写法:配置、缓存、序列化与常见坑

在 Spring Boot 里用 Redis,先分清两种用法

Spring Boot 接 Redis 并不难,真正容易写乱的是使用方式:一类是把 Redis 当成数据结构工具,直接读写 String、Hash、List、Set、ZSet;另一类是把 Redis 当成缓存后端,交给 Spring Cache 的 @Cacheable@CacheEvict 去管理。

这两种方式可以同时存在,但不要混着理解。RedisTemplate 关心的是 Redis 命令和序列化;@Cacheable 关心的是方法结果缓存、过期时间和失效策略。Spring Boot 官方文档也把 Redis 放在 NoSQL 支持里,说明 spring-boot-starter-data-redis 会收集相关依赖,默认使用 Lettuce,并提供 RedisConnectionFactoryStringRedisTemplateRedisTemplate 等 Bean。([Home][1])

引入依赖

如果只是直接操作 Redis,引入这个 starter 就够了:

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

如果还要使用 @Cacheable 这套缓存注解,再加上缓存 starter:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

版本不要单独写死,交给 Spring Boot parent 或 BOM 管理。否则最常见的问题不是代码错,而是 Spring Data Redis、Lettuce、Jackson 之间版本不一致,启动时才爆一堆类找不到或方法签名不匹配。

配置连接信息

Spring Boot 3 之后常用的是 spring.data.redis.* 这组配置:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      database: 0
      username:
      password:
      timeout: 3s

如果 Redis 有密码,填 password。如果使用 Redis URL,也可以这样写:

spring:
  data:
    redis:
      url: redis://user:secret@localhost:6379
      database: 0

官方文档里也明确说明,默认会尝试连接 localhost:6379,也可以用 spring.data.redis.*spring.data.redis.url 指定连接信息。([Home][1])

直接操作 Redis:优先用 StringRedisTemplate 起步

最简单的读写可以从 StringRedisTemplate 开始。它的 key 和 value 都按字符串处理,调试时在 Redis CLI 里也能直接看懂。

@Service
public class RedisStringService {

    private final StringRedisTemplate stringRedisTemplate;

    public RedisStringService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public void saveCode(String phone, String code) {
        String key = "login:code:" + phone;
        stringRedisTemplate.opsForValue().set(key, code, Duration.ofMinutes(5));
    }

    public boolean verifyCode(String phone, String inputCode) {
        String key = "login:code:" + phone;
        String code = stringRedisTemplate.opsForValue().get(key);
        return inputCode != null && inputCode.equals(code);
    }
}

opsForValue() 对应 Redis String,opsForHash() 对应 Hash,opsForList() 对应 List。Spring Data Redis 官方文档里也提到,RedisTemplate 是 Redis 模块的核心类,提供了较高层的 Redis 交互抽象,并封装了序列化和连接管理。([Home][2])

操作对象:不要放任默认序列化

很多项目一开始会直接注入 RedisTemplate<String, Object>,然后把 Java 对象塞进去:

redisTemplate.opsForValue().set("user:1", user);

能跑,但不一定适合长期维护。

RedisTemplate 默认序列化如果没有认真配置,很容易出现两类问题:第一,Redis 里看到的是一串不可读的二进制;第二,类名、包名变化后,历史缓存可能反序列化失败。Spring Data Redis 文档也说明,RedisTemplate 对多数操作默认使用 Java-based serializer,同时也允许替换序列化机制。([Home][2])

更常见的做法是:key 用字符串,value 用 JSON。

@Configuration
public class RedisConfig {

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

        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer jsonSerializer =
                new GenericJackson2JsonRedisSerializer();

        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

然后再封装业务读写:

@Service
public class UserCacheService {

    private final RedisTemplate<String, Object> redisTemplate;

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

    public void cacheUser(UserDTO user) {
        String key = "user:" + user.getId();
        redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
    }

    public UserDTO getUser(Long userId) {
        Object value = redisTemplate.opsForValue().get("user:" + userId);
        if (value == null) {
            return null;
        }
        return (UserDTO) value;
    }
}

这里还有一个实际问题:GenericJackson2JsonRedisSerializer 会带类型信息,适合通用对象缓存;如果你对缓存结构有强约束,也可以用 Jackson2JsonRedisSerializer<UserDTO>,但配置会更啰嗦。没有绝对标准,关键是团队内部保持一致。

使用 Spring Cache:适合“方法结果缓存”

如果某个查询逻辑比较重,比如查数据库、组装 DTO、调用远程接口,可以用 Spring Cache 简化代码。

先开启缓存。不要随手把 @EnableCaching 扔到主启动类上,官方文档也提醒,放在主应用类上会让缓存成为测试套件里的强制特性。更稳妥的是单独建一个配置类。([Home][3])

@Configuration
@EnableCaching
public class CacheConfig {
}

配置 Redis 作为缓存类型:

spring:
  cache:
    type: redis
    redis:
      time-to-live: 30m
      cache-null-values: false

业务代码:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "product", key = "#id")
    public ProductDTO getProduct(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("product not found"));
        return ProductDTO.from(product);
    }

    @CacheEvict(cacheNames = "product", key = "#id")
    public void updateProduct(Long id, ProductUpdateRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("product not found"));
        product.update(request);
        productRepository.save(product);
    }
}

这段代码的意思是:第一次调用 getProduct(1) 时走数据库,返回值写入 Redis;后续相同 key 直接从缓存取。更新商品后,用 @CacheEvict 删除对应缓存。

Spring Cache 的好处是业务代码干净;坏处是你对 Redis 细节的控制变少了。比如不同 cacheName 想配置不同过期时间,就需要自定义 RedisCacheManager

@Configuration
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30))
                .disableCachingNullValues()
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new StringRedisSerializer())
                )
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new GenericJackson2JsonRedisSerializer())
                );

        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
        cacheConfigs.put("product", defaultConfig.entryTtl(Duration.ofMinutes(10)));
        cacheConfigs.put("user", defaultConfig.entryTtl(Duration.ofHours(1)));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigs)
                .build();
    }
}

这个配置比注解多一点,但缓存策略终于落到了代码里,而不是散在各个方法上靠人记。

Key 设计比 API 更重要

很多 Redis 问题不是 RedisTemplate 用错,而是 key 设计太随意。

建议用固定前缀和业务维度:

login:code:13800000000
user:profile:10001
product:detail:9527
order:pay:lock:202605150001

不要这样写:

10001
user10001
product_9527

短期看没区别,线上排查时差很多。规范 key 至少有三个好处:

  1. 能快速判断数据属于哪个业务。
  2. 方便按前缀扫描和清理。
  3. 避免不同模块 key 冲突。

但也别在生产环境随便用 KEYS user:*。数据量大时它会阻塞 Redis。需要巡检或批量清理时,用 SCAN,或者从业务侧维护索引集合。

缓存穿透、击穿、雪崩要分开处理

这三个词经常被放在一起讲,但处理方式不一样。

缓存穿透:查询一个根本不存在的数据,比如用户 id 为 -1。请求每次都打到数据库。常见处理方式是缓存空值,或者做参数校验、布隆过滤器。

@Cacheable(cacheNames = "user", key = "#id", unless = "#result == null")
public UserDTO getUser(Long id) {
    return userRepository.findById(id)
            .map(UserDTO::from)
            .orElse(null);
}

如果业务上允许缓存空值,就不要加 unless = "#result == null",而是给空值设置较短 TTL。

缓存击穿:某个热点 key 过期的一瞬间,大量请求同时打到数据库。常见处理方式是互斥锁、逻辑过期、热点 key 不轻易过期。

缓存雪崩:大量 key 在同一时间过期,数据库突然扛不住。处理方式是 TTL 加随机偏移,避免同批缓存同时失效。

public Duration randomTtl(int baseMinutes) {
    int randomSeconds = ThreadLocalRandom.current().nextInt(0, 300);
    return Duration.ofMinutes(baseMinutes).plusSeconds(randomSeconds);
}

缓存不是只要加上 Redis 就安全了。缓存真正麻烦的地方,是它让数据多了一份副本,而副本一定会带来一致性问题。

分布式锁:能不用就别滥用

Spring Boot 项目里经常会用 Redis 做简单分布式锁,比如防重复提交、定时任务抢占、订单支付状态更新。基本写法是 SET key value NX PX milliseconds,也就是“key 不存在才设置,并且带过期时间”。Redis 官方文档里 SET 命令支持 NXEXPX 等选项,其中 NX 表示 key 不存在才设置,PX 表示设置毫秒级过期时间。([Redis][4])

StringRedisTemplate 可以这样写:

@Service
public class RedisLockService {

    private final StringRedisTemplate stringRedisTemplate;

    public RedisLockService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public boolean tryLock(String key, String value, Duration ttl) {
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(key, value, ttl);
        return Boolean.TRUE.equals(success);
    }

    public void unlock(String key, String value) {
        String script = """
                if redis.call('get', KEYS[1]) == ARGV[1] then
                    return redis.call('del', KEYS[1])
                else
                    return 0
                end
                """;

        stringRedisTemplate.execute(
                new DefaultRedisScript<>(script, Long.class),
                Collections.singletonList(key),
                value
        );
    }
}

这里解锁不能直接 delete(key)。原因很简单:锁可能已经过期,又被别的线程拿到了。直接删除会把别人的锁删掉。所以加锁时要写入唯一 value,解锁时用 Lua 脚本判断 value 一致才删除。

如果业务对锁要求很高,比如锁续期、可重入、公平性、主从切换风险都要考虑,别自己硬写一套,直接用 Redisson 更现实。

常见坑

1. 忘记设置过期时间

登录验证码、短信频控、临时 token 这类数据必须设置 TTL。没有 TTL 的缓存,最后都会变成历史垃圾。

stringRedisTemplate.opsForValue()
        .set("login:code:" + phone, code, Duration.ofMinutes(5));

2. 缓存和数据库更新顺序随便写

一般更新数据时,更常见的策略是:先更新数据库,再删除缓存

@Transactional
public void updateUser(Long id, UserUpdateRequest request) {
    userRepository.updateUser(id, request);
    redisTemplate.delete("user:profile:" + id);
}

不要优先考虑“更新数据库后再更新缓存”。只要并发稍微复杂一点,缓存就可能被旧数据覆盖。删除缓存虽然看起来粗糙,但失败模式更简单。

3. 大 key

一个 Hash 里塞几十万个 field,一个 List 无限增长,一个 value 存几 MB JSON,都会拖慢 Redis。Redis 是单线程执行命令模型的典型代表,大 key 的问题通常不是“存不下”,而是删除、迁移、网络传输、慢查询都变重。

4. 把 Redis 当数据库

Redis 可以持久化,但它不是关系数据库的替代品。订单、账户余额、交易流水这类核心数据,主数据应该落在数据库里。Redis 更适合做缓存、计数器、排行榜、分布式协调、短期状态。

一个比较稳的落地方式

普通 Spring Boot 项目里,可以按这个顺序使用 Redis:

  1. 简单字符串场景,用 StringRedisTemplate
  2. 对象缓存,明确配置 JSON 序列化。
  3. 方法级缓存,用 Spring Cache,但要统一 TTL 和 key 规则。
  4. 分布式锁只处理轻量并发控制,复杂锁交给成熟组件。
  5. 所有 Redis key 都要有命名规范,临时数据必须有过期时间。

这样用 Redis 不花哨,但稳定。项目里最怕的不是少用了几个高级命令,而是缓存逻辑散落在各处、key 没规范、序列化不统一、过期策略靠运气。前期省下的几分钟,后面基本都会在排查线上脏数据时还回去。

参考资料

  • Spring Boot Reference Documentation:Working with NoSQL Technologies / Redis。([Home][1])
  • Spring Boot Reference Documentation:Caching。([Home][3])
  • Spring Data Redis Reference Documentation:Working with Objects through RedisTemplate。([Home][2])
  • Redis 官方文档:SET command。([Redis][4])
正文到此结束
评论插件初始化中...
Loading...