1. 先搞清楚:为什么默认配置的RedisTemplate不能直接用?
我先说一个很多项目里出现过的真实场景:新服务上线,内存缓存用的是Redis,开发阶段一切正常,结果灰度一放量,Redis里出现大量形如\xAC\xED\x00\x05t\x00\x04name的乱码键,GC压力升高,内存翻倍。查了半天定位到原因——默认的RedisTemplate用的是JDK序列化。这个坑,几乎每个从零搭Redis的团队都会踩一次。
标题里说的"从零搭建Redis生产级应用",绝不是把spring-boot-starter-data-redis加进依赖就完事。真正要做的,是把连接工厂(ConnectionFactory)、序列化器(Serializer)、RedisTemplate这三层彻底吃透,并且根据自己的业务场景做定制。这篇教程就按这个顺序一层一层拆开来讲,每一步都给到可以直接抄的配置、代码和理由。
先给一个整体认知框架。我们用Java操作Redis,整个链条大概是这样的:
- Lettuce/Jedis:底层连接Redis服务器的客户端驱动。
- RedisConnectionFactory:负责创建和管理这些底层连接,就像数据库连接池。
- RedisTemplate:Spring Data Redis提供的顶层操作封装。你写
redisTemplate.opsForValue().set("key", "value"),它内部会走连接工厂拿连接,然后通过序列化器把key和value变成字节,再发给Redis服务器。
很多人只关注第三层,忽略了前两层,结果生产环境一压测就出问题。所以本文的章节安排是:先解决序列化家族的问题(因为这是"数据能不能正确读写"的根本),再深入连接工厂的每个参数(这是"高并发下系统稳不稳"的关键),最后把RedisTemplate的生产级封装完整落地。期间会穿插大量我在生产线上的排查经验。
2. 序列化器家族全景:选错一个,数据就废一半
2.1 四个主流序列化器的性格特点与适用边界
Redis本身只存字节,不关心你存的是String还是对象。所有"可读性""跨语言""内存占用"的诉求,全都要靠序列化器实现。我先把Spring Data Redis中最常见的四个序列化器列出来,用表格做一个直观对比:
| 序列化器 | 存储格式 | 可读性 | 跨语言 | 占用空间 | 推荐场景 |
|---|---|---|---|---|---|
| JdkSerializationRedisSerializer | JDK二进制 | 极差 | 极差 | 很大 | 不推荐,除非只做临时缓存 |
| StringRedisSerializer | 纯字符串 | 好 | 好 | 小 | key,以及纯字符串value |
| Jackson2JsonRedisSerializer | JSON | 好 | 较好 | 中等 | value为对象,能明确指定类型 |
| GenericJackson2JsonRedisSerializer | JSON(含@class类型) | 好 | 一般 | 较大 | value为对象,类型多且不想逐个配置 |
默认的RedisTemplate使用的是JdkSerializationRedisSerializer。它最大的问题有三个:第一,序列化后的字节里带类全限定名和内部结构,非常占空间,实测一个大对象比JSON方式能多出两到三倍的体积;第二,只有Java程序才能反序列化,接口如果被Go、Python服务调用,数据直接没法解析;第三,类结构一旦变化,反序列化容易报错。当你看到Redis里出现\xAC\xED开头的key或者value时,不用怀疑,就是JDK序列化的产物。
StringRedisSerializer是最简单的,直接按UTF-8编码转字节。它通常用来序列化key,这样在Redis Desktop Manager这类可视化工具里能直接看到user:info:123这样清晰的键名,而不是\xAC\xED乱码。这也是很多团队约定俗成的做法:key一律用String序列化器,value再根据业务形态选择。
Jackson2JsonRedisSerializer和GenericJackson2JsonRedisSerializer是我们处理value的主力选手。它俩的区别要重点说:Jackson2JsonRedisSerializer在构造时必须明确指定一个对象类型,比如new Jackson2JsonRedisSerializer<>(User.class),反序列化时它就按这个固定类型去解析;而Generic版本会在JSON里额外写一个@class字段,把原始类路径记录下来,反序列化时根据这个字段动态还原。前者性能略好、代码更可控,后者灵活但多存了一些类型元数据,而且存在安全风险,后面我细讲。
2.2 为什么key和value必须分开配置序列化器
新手最容易犯的一个错误,是给RedisTemplate四个方法(keySerializer、valueSerializer、hashKeySerializer、hashValueSerializer)全部塞同一个序列化器。绝大多数情况下,key用String、value用JSON才是最优组合。
原因在于读写逻辑的语义天然不同。key是检索的入口,必须稳定、简洁、可读。试想一个场景:运营同学要手动去Redis里删一批缓存key,如果key是JDK二进制乱码,他根本不知道哪个key对应哪个业务;如果key是String序列化,他可以一眼看出user:login:token:9527是哪个用户的登录态。而value承载的是复杂业务对象,用JSON序列化,让结构与字段清晰可见,排查问题的时候直接读Redis里的JSON比反序列化再Debug高效得多。
hash的结构同理。如果一个hash的field非常多,field名也要保证可读性。我见过一个项目,hashKey用了JDK序列化,结果在可视化客户端看hash时,field全是一串十六进制转义序列,根本没法人工核对数据。所以我的习惯是:redisTemplate的keySerializer、hashKeySerializer一律用StringRedisSerializer;valueSerializer优先用GenericJackson2JsonRedisSerializer;hashValueSerializer视hash内实际存储内容决定——如果存储的是简单字符串,用String即可,如果也是对象,就用GenericJackson2JsonRedisSerializer。
2.3 手写一个"安全版"GenericJackson2JsonRedisSerializer
前面我提到Generic版本有安全风险,这里得展开讲。GenericJackson2JsonRedisSerializer在反序列化时会根据@class字段指定的类去实例化对象。如果Redis被攻击者写入恶意构造的JSON,就可能触发不安全的反序列化行为,类似Fastjson那类漏洞的原理。Spring官方其实也意识到这个问题,默认配置里给了一个BasicJsonObject的允许列表,但如果你直接把ObjectMapper换成自定义的,等于把这个保护关了。
生产级做法是在自定义ObjectMapper时,手动配置一个允许反序列化的白名单。下面这段配置可以直接参考:
@Bean public RedisSerializer<Object> redisSerializer() { ObjectMapper objectMapper = new ObjectMapper(); // 允许序列化空对象,否则某些getter返回null的对象会序列化失败 objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 反序列化时遇到未知字段不要报错,保证兼容性 objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 启用@class字段,允许携带类型信息 objectMapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); // 白名单:只允许特定包前缀下的类被反序列化 objectMapper.registerModule(new SimpleModule()); // 关键:配置白名单校验器,防止任意类反序列化 objectMapper.setPolymorphicTypeValidator(polymorphicTypeValidator()); return new GenericJackson2JsonRedisSerializer(objectMapper); }白名单校验器的实现逻辑是:检查类型名是否以指定的包前缀开头,只有匹配的才放行。这是经验之谈,我在一个对外提供缓存服务的中间件里,就是这么遏制住的恶意反序列化尝试。
3. 连接工厂:Lettuce、Jedis和连接池参数的取舍
3.1 默认选Lettuce还是Jedis,生产依据是什么
Spring Boot 2.x默认使用Lettuce作为Redis客户端,3.x也是。Jedis则是很多老项目的选择。两者的核心差异在连接模型上。
Jedis是阻塞式I/O,操作是同步的,每个线程持有独立的连接实例,所以早期用它必须配连接池来复用连接。Lettuce基于Netty,使用共享连接,多个线程可以并发使用同一个连接,底层I/O是异步事件驱动的,吞吐量上限更高。Lettuce还支持Redis Sentinel、Redis Cluster等拓扑的自动节点发现和拓扑刷新,这对生产环境的故障转移很关键:主节点挂了,Lettuce能感知到新主节点并自动切换连接,而Jedis需要手动处理。
所以在新建项目时,我不会犹豫,直接用Lettuce。除非是历史遗留项目强制用Jedis,否则Lettuce是更省心的选择。
3.2 连接池到底配不配,配多少合适
这里有一个反转认知:Lettuce本身是共享连接模型,默认情况下,Spring Boot Data Redis给的Lettuce连接工厂是不启用连接池的。但生产环境我的建议是:连接的线程安全性不等于免费无限并发,高峰期的并发操作落在一条共享连接上,仍然会出现排队和阻塞。尤其是执行pipeline、transaction这些批量操作时,单连接无法提供足够的吞吐隔离。
Lettuce官方其实提供了连接池的支持,只是需要额外引入commons-pool2依赖。配置好后,Spring Boot的LettuceConnectionFactory就能感知到GenericObjectPoolConfig。
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>核心配置如下:
spring: data: redis: host: 192.168.1.100 port: 6379 password: yourpassword database: 0 timeout: 3s lettuce: pool: max-active: 32 max-idle: 16 min-idle: 4 max-wait: 2s time-between-eviction-runs: 30s参数含义和取值逻辑是:
- max-active=32:连接池最多同时给出32个连接。这个值不是拍脑袋,我一般按"应用峰值QPS × 单操作平均耗时 / 1000 × 冗余系数"粗算。例如峰值QPS 5000,每次Redis操作平均2ms,理论上需要10个连接,为了应对突发再乘2倍左右,32就够用了。
- max-wait=2s:等待获取连接的最长时间。超过这个时间如果还没拿到连接,直接抛异常而不是无限等待。有些系统在这个参数上配置过大,结果Redis抖动时,线程全部hang在等待连接上,最后拖垮了整个应用。
- max-idle/min-idle:保持空闲连接数量。初始化4个空闲连接,避免刚启动时频繁创建连接导致的前几次请求延迟偏高。
- time-between-eviction-runs=30s:空闲连接回收的扫描间隔。
3.3 生产环境真正该关注的三个隐藏参数
很多人配置完上面这些就停了,实际上Lettuce还有几个隐藏参数在生产环境很关键。
第一个是timeout。这个timeout指的是命令执行的超时时间,不是连接建立的超时。我见过一个案例:Redis慢查询把某条命令拖了几秒钟,应用侧默认超时时间没配置,结果这条慢命令一直占着连接,后续所有请求都在排队。设置了3秒甚至更短的timeout后,慢命令会快速失败并抛出异常,至少不会拖垮全体请求。
第二个是lettuce.shutdown-timeout。应用优雅停机时,如果Lettuce线程池还在处理请求,不断开的连接会拖慢下线过程。建议显式设置:
spring: lifecycle: timeout-per-shutdown-phase: 10s第三个是验证连接是否存活。Lettuce默认不主动发送PING命令检测连接健康,长时间空闲的连接可能已经被Redis服务端断开而不自知。虽然Lettuce内部有自动重连机制,但定期的validateConnection能更早暴露问题。在自建的Redis连接工厂里可以开启:
factory.setValidateConnection(true);3.4 自定义连接工厂的完整代码参考
把连接池和超时参数都整合进一个自定义配置类,生产可以直接拿去改改用:
@Configuration public class RedisConfig { @Bean public LettuceConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration(); serverConfig.setHostName("192.168.1.100"); serverConfig.setPort(6379); serverConfig.setPassword(RedisPassword.of("yourpassword")); serverConfig.setDatabase(0); LettucePoolingClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder() .commandTimeout(Duration.ofSeconds(3)) .shutdownTimeout(Duration.ofSeconds(5)) .poolConfig(poolConfig()) .build(); LettuceConnectionFactory factory = new LettuceConnectionFactory(serverConfig, clientConfig); factory.setValidateConnection(true); return factory; } private GenericObjectPoolConfig<?> poolConfig() { GenericObjectPoolConfig<?> config = new GenericObjectPoolConfig<>(); config.setMaxTotal(32); config.setMaxIdle(16); config.setMinIdle(4); config.setMaxWait(Duration.ofSeconds(2)); config.setTestOnBorrow(true); config.setTestOnReturn(false); return config; } }4. RedisTemplate封装:API执行逻辑与生产级代码落地
4.1 我们为什么很少直接用RedisTemplate的原生方法
直接注入RedisTemplate<String, Object>然后用opsForValue().set(),是最简单的写法,但在一个真正的大型项目里,你会发现原生方法有两个别扭的地方。
第一,opsForValue()、opsForHash()这些方法返回的操作器是每次临时获取的,大量散落在业务代码里,导致后续如果要统一加缓存穿透保护、统一加监控埋点,需要改的地方非常多。
第二,RedisTemplate原生方法抛出的异常是Spring Data的DataAccessException体系,业务方需要额外catch,不然异常信息不太友好。
所以生产项目的标准动作是:在RedisTemplate之上再做一层轻量封装,提供一个CacheService,内部统一处理序列化、异常转换、空值标记。
4.2 带有序列化配置的Template定义方式
第一种方式,直接以Bean形式定义RedisTemplate:
@Configuration public class RedisTemplateConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); // key使用String序列化器 StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); // value使用GenericJackson2JsonRedisSerializer RedisSerializer<Object> jsonSerializer = redisSerializer(); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }注意这里有个小细节:setConnectionFactory不会立刻真正创建连接,配置的生效得靠afterPropertiesSet()触发。如果省略这个方法,序列化器和连接工厂的初始化顺序可能不对,运行时会报奇怪的NPE。
第二种方式是使用Spring Boot自动配置的RedisTemplate<Object, Object>,但它的泛型是Object,实际使用时要强转,很不方便。所以我更推荐上面这种手动定义的模板,泛型直接限定为String和Object,key的类型安全在编译期就保证了。
4.3 StringRedisTemplate和RedisTemplate的关系,以及数据互通问题
Spring Boot里还自动配置了一个StringRedisTemplate,它其实是RedisTemplate的子类,内部把key和value都固定用StringRedisSerializer序列化。很多项目的踩坑点由此而来:一个服务用StringRedisTemplate写入数据,另一个服务用自定义RedisTemplate读取,结果读不到——因为key的序列化方式一样,问题不大,但value一个存了纯字符串(StringRedisTemplate),一个存了带引号的JSON字符串(GenericJackson2JsonRedisSerializer),格式完全不同,读出来要么强转失败,要么内容对不上。
解决这个问题的唯一办法是:在一个服务内统一缓存读写组件的序列化策略。多个服务共享同一套Redis时,更要在接口文档或公共SDK里明确约定key和value的格式。我见过最稳妥的做法是,抽取一个公共的CacheModule,所有服务引用同一个模块,序列化配置、key命名规范、过期策略全部统一定义,哪种Template都不允许绕过。
4.4 在Template之上封装统一缓存服务:核心代码
下面这段封装覆盖了最常见的场景,也是我个人项目的标配:
@Service public class CacheService { private final RedisTemplate<String, Object> redisTemplate; public CacheService(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } public void set(String key, Object value, long timeout, TimeUnit unit) { redisTemplate.opsForValue().set(key, value, timeout, unit); } public boolean setIfAbsent(String key, Object value, long timeout, TimeUnit unit) { return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); } public Object get(String key) { return redisTemplate.opsForValue().get(key); } public void delete(String key) { redisTemplate.delete(key); } 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 expire(String key, long timeout, TimeUnit unit) { redisTemplate.expire(key, timeout, unit); } public Long increment(String key, long delta) { return redisTemplate.opsForValue().increment(key, delta); } public boolean tryLock(String key, String requestId, long timeout, TimeUnit unit) { return setIfAbsent(key, requestId, timeout, unit); } public void unLock(String key, String requestId) { // 使用Lua脚本保证原子性删除,避免误删其他线程的锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; // 这里用DefaultRedisScript执行 } }这段代码同时实现了分布式锁的基础版:setIfAbsent加过期时间作为互斥锁,释放锁时用Lua脚本比对requestId,防止线程A把线程B的锁删了。这是生产环境必踩的坑:如果释放锁不判断持有者,一个慢请求过期后,后来者拿到锁,结果前一个请求执行完把锁误删,锁就完全失效了。
5. 实战中的序列化冲突与类型转换陷阱,以及监控兜底
5.1 经典事故:LocalDateTime序列化失败
如果你把Java 8的LocalDateTime直接塞进RedisTemplate存对象,用GenericJackson2JsonRedisSerializer序列化时十有八九会报错。原因是Jackson默认不支持Java时间类型,必须注册JavaTimeModule。
在自定义ObjectMapper时,记得加上:
objectMapper.registerModule(new JavaTimeModule()); objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);第二行配置是为了把日期序列化成可读的ISO字符串而不是数字时间戳。不加的话,LocalDateTime会变成[2024, 5, 20, 14, 30, 25]这种数组,服务端解析虽然也能对应上,但阅读性极差,排查数据问题的时候看得一头雾水。
5.2 经典翻车:BigDecimal反序列化后变成Integer或Double
用GenericJackson2JsonRedisSerializer存BigDecimal,反序列化时经常出现类型不匹配。比如数据库里某个字段是BigDecimal,你写入Redis后变量声明也是BigDecimal,但读出来却发现是Double,一强转就ClassCastException。
原因是JSON序列化会把BigDecimal按照数值类型输出,比如100.00存成100.00时还可以,但100就会输出成100,反序列化时Jackson把它当作Integer处理,类型信息在JSON里丢失了。解决这个问题的思路有两个:
一是尽量用字符串表示金额字段,在业务对象里把BigDecimal属性的getter/setter改成String存取,或者加一个@JsonSerialize(using = ToStringSerializer.class)注解,强制序列化为字符串:
public class Product { @JsonSerialize(using = ToStringSerializer.class) private BigDecimal price; }二是严格要求所有涉及金额的核心数据不落地缓存,只用Redis做临时读取。这个思路简单粗暴,但很有效,我倾向于推荐它——金额字段越少经过各种中间件,出大问题的概率越低。
5.3 连接池耗尽时的现象、定位与处理
连接池耗尽在生产上是会真实发生的,现象非常典型:应用日志里大量出现Unable to connect to Redis和RedisConnectionFailureException,同时接口响应时间从几十毫秒飙升到几秒。这时候很多人的第一反应是Redis服务器挂了,但实际去redis-cli info clients一看,连接数并正常运行。
真正的问题往往出在应用侧的慢查询。某条命令执行了很长时间,占着连接不释放,在max-active=32的池子里,32个连接都被慢命令占满,后续请求全部阻塞在等待连接上,直到2秒的max-wait超时后抛出异常。
定位手段有三个:
- 用
redis-cli slowlog get查看慢命令,重点看哪些key的操作耗时超过了slowlog-log-slower-than阈值。 - 查应用线程栈(
jstack),看线程是阻塞在pool-2-thread-1上的连接租借代码,还是真的进入了网络I/O等待。 - 检查是否有大key操作,比如一次性
HGETALL一个几十万字段的hash,或者直接SMEMBERS一个千万级别的set。这种命令会把网络、序列化、Redis单线程处理全部拖慢。
处理方案是把大key拆成多个小key分片,或者把批量读取改成SCAN游标分批取。同时把max-wait设短一些,宁可快速失败也不要拖垮整个服务。
5.4 生产环境建议开启的Redis监控指标
最后聊一下兜底。连接池、序列化、超时配置再完善,也没法避免Redis集群本身出故障,所以监控必须配好。
Spring Boot Actuator天然支持Redis的健康检查,在application.yml里开启:
management: endpoints: web: exposure: include: health,info,metrics health: redis: enabled: true同时接入Micrometer后,可以采集lettuce.command.measurements这类指标。更细粒度的监控还应该包括:Redis实例的hit_rate(缓存命中率)、connected_clients(客户端连接数)、used_memory_rss(内存占用)、instantaneous_ops_per_sec(每秒操作数)。配合告警规则,比如命中率低于80%或内存超过maxmemory的80%就触发告警,很多问题可以在用户感知之前处理掉。
6. 大流量场景下再进阶:管道、二级缓存和分布式锁
6.1 管道(Pipeline)解决批量操作的性能瓶颈
如果业务中经常需要一次性写入大量key,比如初始化用户标签、批量导入配置,逐条set是非常低效的。每条命令都要经历一次网络RTT(Round-Trip Time),在远程Redis上这个损耗非常明显。
用RedisTemplate执行管道,核心代码是这样的:
List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) throws DataAccessException { for (int i = 0; i < 10000; i++) { operations.opsForValue().set("bulk:key:" + i, "value-" + i); } return null; } });管道内部会把多条命令一次性打包发给Redis服务端,再将结果一次性读回,减少了大量网络交互。实测在局域网里,批量写1万条数据,逐条写耗时约12秒,管道方式耗时约0.5秒,差距接近一个数量级。这里要注意的是,管道操作会占用一个连接较长的时间,如果连接池较小,建议控制批量条数,或者分批执行。
6.2 二级缓存:Redis和本地缓存Caffeine的协作模式
生产级应用在高并发下,有一种经典组合:Caffeine本地缓存 + Redis远程缓存。流程是先查Caffeine,未命中再查Redis,再未命中才查数据库,然后把结果回填到本地和远程两级缓存。
为什么这么做?因为Redis再快,一次网络RTT也有0.1ms到1ms级别的开销。而在应用内直接读内存,只要纳秒级别。对于热点数据,比如首页推荐位、秒杀商品详情,这一层本地缓存能挡掉大量Redis请求。
简单封装思路:
@Service public class CacheAsideService { private final Cache<String, Object> localCache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(Duration.ofMinutes(10)) .build(); private final CacheService redisCacheService; public Object get(String key) { Object value = localCache.getIfPresent(key); if (value != null) { return value; } value = redisCacheService.get(key); if (value != null) { localCache.put(key, value); } return value; } public void evict(String key) { localCache.invalidate(key); redisCacheService.delete(key); } }这里有个一致性问题要特别留意:更新数据时,先删缓存还是先更新数据库,顺序不同会导致不同的脏数据窗口。我的习惯是"先更新数据库,再删除缓存",而不是"先删除缓存再更新数据库"。因为前者只有在删除缓存失败时才会出现短暂脏读,后者在更新数据库的窗口期内容易把旧数据又写回缓存,造成更长的脏数据。配合缓存的TTL兜底,最终会收敛。
6.3 从手写锁到Redisson:分布式锁的正确进化路径
前面的CacheService里我写了一个基于setIfAbsent的基础分布式锁,它够用,但不完善。比如锁没有自动续期机制,如果持有锁的线程执行时间超过了锁的TTL,锁会被其他线程抢走,原来的线程还以为自己安全持有锁。
生产级方案是使用Redisson,它内部的看门狗机制会自动续期,默认每10秒检查一次,如果锁还在被当前线程持有,就自动延长过期时间:
RLock lock = redissonClient.getLock("order:pay:" + orderId); boolean locked = false; try { locked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { // 业务逻辑 } } finally { if (locked) { lock.unlock(); } }Redisson还在底层处理了主从切换时锁丢失的问题,提供了RedissonRedLock等多锁方案。但从成本与复杂度平衡来看,大多数业务用到单体Redis加Redisson的锁就够了。Redis集群的RedLock方案只有在极其严苛的分布式一致性要求下才需要考虑。
6.4 Sentinel集群与主从方案在生产中的定位
标题相关热词里有"redis主从""redis哨兵集群"这些关键词,我在这里说个定调:如果你的Redis已经在生产环境承担了核心缓存职责,千万不要只用单机,至少要上主从加Sentinel的架构。
主从提供了数据冗余和读写分离的可能,Sentinel解决的是故障自动切换。在Java侧,Lettuce支持Sentinel拓扑:
spring: data: redis: sentinel: master: mymaster nodes: 192.168.1.101:26379,192.168.1.102:26379,192.168.1.103:26379这样配置后,某个Sentinel节点挂了,Lettuce感知拓扑变化的能力会自动切换到新的Sentinel节点;Redis主节点故障时,Sentinel选主后客户端也会自动重建连接。这也是我在前文反复强调选Lettuce的重要原因——它在客户端层面就具备拓扑感知能力,生产环境省心很多。
我踩过一次让人很难忘的坑:当时只做了主从没有配Sentinel,晚上主库宕机,从库数据读到一半,应用侧报错堆满日志,直到第二天早上人工切换。那次之后我就把所有核心链路都迁移到了Sentinel模式。所以如果你问生产环境最底线的配置是什么,我的答案很直接:主从加Sentinel,或者直接Cluster,单机只配做开发环境。
7. 最后的几个经验补充
踩过这么多坑之后,有几个细节值得再说一遍。
一个是Redis的maxmemory-policy淘汰策略。没设淘汰策略的话,内存满了新数据写不进去,线上会直接报OOM错误。生产环境中我一般设置allkeys-lru,因为缓存场景下热度数据保留价值远大于冷数据,LRU最适合大多数业务。没有特殊理由不要用noeviction。
另一个是key命名规范。我强烈建议在项目启动时就定好统一前缀,比如biz:module:func:id,用冒号分隔。既方便按业务模块排查,也方便用SCAN按前缀批量清理。不过要注意,KEYS user:*这种命令在生产大库上执行会阻塞Redis单线程,清理缓存一定要用SCAN游标。
还有一个是关于连接工厂初始化的时机问题。有些代码在Spring容器还没完全初始化时就调用了RedisTemplate,导致RedisConnectionFailureException。标准的做法是在ApplicationRunner或@PostConstruct里做预热连接,比如启动时执行一次PING。虽然是个小动作,但能有效避免业务流量刚进来时第一批请求撞上"连接未就绪"的窗口。
到这一步,从连接工厂的参数、序列化器的选择、Template的定制封装到监控、锁、二级缓存,一个能扛住生产流量压力的Redis接入方案就算真正成型了。这套配置我在多个线上项目中反复打磨过,你照着做大概率不会再碰到那些让人半夜爬起来排查的基础性问题。