☰
Java项目Redis实战指南:客户端选型、序列化与分布式锁
2026/10/9 2:19:49 网站建设 项目流程

1. 为什么Java项目里操作Redis,很多老手第一句话都是“别用客户端当数据库”

先说个场景。某天你接手一个Java服务,发现里面用Redis存用户会话,代码里到处是set、get,再仔细一看,居然还有人把订单详情整个序列化塞进去,过期时间设成了一天。线上一压测,Redis内存直接飙到几个G,CPU也时常报警。这时候你就明白,Redis在Java项目里被用歪了,比不会用更可怕。

这个标题覆盖的范围其实挺宽泛的,从Java原生客户端到Spring Data Redis到Spring Boot自动配置,内容能写一本书。但大多数人真正的痛点其实是那几个:怎么连、怎么封装、怎么避免Key过期和内存浪费、怎么在Spring环境里少写重复代码。所以我这篇不是讲API手册,而是从一个实际项目折腾的角度,把“在Java里操作Redis”这条链路捋清楚,包括选型理由、核心API使用逻辑、Spring环境下的封装差异,还有一堆我在真实环境里踩过的坑。

适合谁看?刚接触Redis不久、想搞明白Java里到底用Jedis还是Lettuce的初学者,以及在Spring Boot项目里用过Redis但总感觉配置没吃透、序列化总是乱码、管道和事务不敢用的中级开发者。文章里的代码都是Java 8 以上可跑,Spring Boot 用 2.x 和 3.x 都兼容,关键依赖和配置我会写清楚。

先交代一个我自己的基本盘:我平时项目里Redis的主要用途是缓存热点数据、分布式锁、接口幂等、排行榜和简单的消息队列,基本不碰那些特别重的模块。下面所有经验都围绕这几个场景展开,针对性足够强,也不至于把范围扯得太散。

2. 动手之前先认清三件事:Redis版本、Java客户端选型、连接池参数

2.1 版本差异容易让你“照着教程写却连不上”

Redis服务端版本和Java客户端的兼容性,很多人不重视,直到线上出问题才回头查。这里的最基本事实是:Redis 6.0 之前默认不支持ACL,Redis 6.0之后引入了ACL和更细粒度的权限控制;RESP协议从Redis 6.0开始支持RESP3,但绝大多数Java客户端默认还是用RESP2协议通信。

实操中我建议直接用Redis 6.x或7.x,原因不复杂:ACL能力在做多环境隔离时很有用,而且新版对内存淘汰策略的文档和工具链支持更完整。如果你还在用Redis 5.x,也不是不能跑,但别用那些只有新版本才支持的指令,比如SET命令的GET选项在旧版上表现不一致。

客户端版本也同理。Jedis 4.x和Lettuce 6.x很多API名字都没变,但连接池参数、超时时间单位、异常类型做了调整。你拿网上三年前的教程配上最新客户端,很容易出现redis.clients.jedis.exceptions.JedisConnectionException这类报错,排查半天发现只是参数名变了。

2.2 Jedis还是Lettuce:这不是二选一,是看场景

Java生态里最主流的两个客户端就是Jedis和Lettuce。都到2025年了,还是有人把这两个对立起来,实际用起来各有各的脾气。

Jedis的特点是直接、粗暴、贴近原生命令。它的Jedis对象就是一次连接的封装,连完就关,或者从连接池借出来用完归还。优点是API和你敲Redis命令的感觉完全一致,jedis.set(key, value)、jedis.expire(key, seconds),没有任何中间层,排查问题非常直观。缺点是它本身是阻塞式IO,每个命令都要独占连接,高并发下必须依赖连接池,不然连接数一多就扛不住。

Lettuce的特点是异步、响应式、基于Netty。它底层是连接复用的,多个线程可以共享同一组连接,靠异步IO和多路复用撑高并发。所以在Spring Boot 2.x之后,Spring官方把默认客户端从Jedis换成了Lettuce,很大程度上就是看中它的连接利用率和性能上限。

我个人的选型建议很简单:

场景推荐
快速原型、临时脚本、要快速看效果Jedis
Spring Boot项目、高并发读写、连接资源敏感Lettuce
需要异步/响应式APILettuce
需要原生命令逐一对照、调试方便Jedis

关于Lettuce的线程安全问题,我一直以来的结论是:Lettuce的RedisConnection是线程安全的,但如果你用了connect()显式获取连接再手动收放,那还是要注意别共享同一个连接做长时间阻塞操作。Spring封装好的StringRedisTemplate和RedisTemplate本身是线程安全的,放心用。

2.3 连接池参数别照着默认抄,四个参数必须自己调

很多人在Spring Boot里配置Redis连接池时,就写个max-total=8然后就跑起来了。这个默认值来自通用连接池的保守设定,放到Redis场景经常不够用。

我自己的经验值,供参考:

spring: redis: host: 127.0.0.1 port: 6379 password: yourpassword # 没有密码就不写 database: 0 # 默认0库,多环境隔离建议分开 timeout: 3000ms lettuce: pool: max-active: 32 # 最大连接数 max-idle: 16 # 最大空闲连接 min-idle: 4 # 最小空闲连接 max-wait: 3000ms # 获取连接超时 shutdown-timeout: 100ms

max-active为什么是32而不是8?因为Lettuce虽然是连接复用,但Spring封装后仍然会为阻塞操作、事务、管道等场景申请额外连接。8太小,高并发时后面请求全在等连接;32是个相对均衡的值,常规业务足够,又不至于浪费文件描述符。

max-wait尤其重要。线上出现过一窝蜂超时的情况,就是因为某个慢查询把连接池占满,后续所有请求在max-wait内没等到连接,直接报RedisConnectionFailureException。把max-wait设成3000ms,再配合监控连接池使用率,能尽早发现问题。

database这个配置项,我建议每个环境独占一个库位,比如dev用0、test用1、prod用2。这样做的好处是环境隔离清晰,清数据方便,误操作影响面可控。注意Redis的库是逻辑库,不是物理隔离,性能上没什么本质差别。

3. 原生客户端实操:从Jedis直连到Lettuce异步,把命令搬到Java里

3.1 Jedis入门:连接、设值、过期时间的三件套

先用一个最简单的Maven依赖搞定Jedis:

<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>4.4.3</version> </dependency>

连接方式有两种:直连和连接池。小工具、测试代码、一次性脚本用直连没毛病,生产环境必须上连接池。别贪图方便直连,Redis连接建立是有开销的,高并发下每次新建连接都在消耗TCP握手和内存。

// 直连方式 Jedis jedis = new Jedis("127.0.0.1", 6379); jedis.auth("yourpassword"); // 有密码才需要 jedis.set("name", "zhang3"); jedis.expire("name", 60); System.out.println(jedis.get("name")); jedis.close();

连接池方式:

JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(32); config.setMaxIdle(16); config.setMinIdle(4); config.setMaxWaitMillis(3000); JedisPool pool = new JedisPool(config, "127.0.0.1", 6379, 3000, "yourpassword"); try (Jedis jedis = pool.getResource()) { String result = jedis.set("name", "zhang3", "NX", "EX", 60); // 返回OK说明设置成功,返回null说明Key已存在 }

这里的set("name", "zhang3", "NX", "EX", 60)就是分布式锁最常用的原子写法,同时满足“不存在才设置”和“设置过期时间”,避免先setnx再expire两步操作带来的非原子风险。

Jedis的API基本就是Redis命令的镜像,hset、rpush、zadd这些都能直接调。遇到冷门命令也不用慌,jedis.sendCommand()能搞定绝大多数。

3.2 Lettuce连接复用和异步API的细节

Lettuce的依赖:

<dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>6.3.0.RELEASE</version> </dependency>

基本连接方式:

RedisClient client = RedisClient.create("redis://password@127.0.0.1:6379/0"); StatefulRedisConnection<String, String> connection = client.connect(); RedisStringCommands<String, String> commands = connection.sync(); commands.set("name", "zhang3"); System.out.println(commands.get("name")); connection.close(); client.shutdown();

Lettuce最吸引人的异步能力,体现在async()和reactive()两套API上。异步用的是CompletableFuture,配合Java 8的链式调用很舒服:

RedisAsyncCommands<String, String> async = connection.async(); async.set("name", "zhang3").thenAccept(result -> { System.out.println("异步写入结果: " + result); });

有一点要注意:异步结果不是立即返回的,别在thenAccept里写那种会阻塞的操作,否则异步的优势就没了。还有,异步命令在连接关闭时会收到异常,批量提交的任务如果中间某个失败,后续任务是继续执行还是整体回滚,取决于你用的是不是事务包裹。

Lettuce底层靠Netty做多路复用,所以很多命令可以被同一个连接并发处理。这跟Jedis每个请求独占一条连接完全不同。这也是为什么Spring默认选Lettuce的原因——连接数需求低,系统资源占用小,吞吐量上限高。

3.3 管道(Pipeline)和事务(Transaction):一次往返解决N个命令

这俩经常被放在一起说,但适用场景完全不同。管道的核心是减少网络往返时间(RTT),把一批命令打包发送,服务端依次执行,中间不等待每个命令的响应,最后一次性返回结果。事务的核心是保证这批命令要么都执行要么都不执行。

管道适合批量写入、批量查询,比如初始化一批用户缓存、批量设置排行榜初始分。Jedis里用pipeline:

try (Jedis jedis = pool.getResource()) { Pipeline pipeline = jedis.pipelined(); for (int i = 0; i < 1000; i++) { pipeline.set("user:" + i, "value:" + i); pipeline.expire("user:" + i, 3600); } // 提交并返回所有结果 List<Object> results = pipeline.syncAndReturnAll(); }

注意syncAndReturnAll()返回的结果列表顺序和命令提交顺序是一致的。如果只调用sync(),则只负责发送,不关心结果,性能稍好一点。我建议能拿结果就拿,因为测试监控时需要确认实际成功数。

事务在Java里的写法通常是multi、exec两条命令包起来:

try (Jedis jedis = pool.getResource()) { Transaction tx = jedis.multi(); tx.set("key1", "value1"); tx.incr("counter"); List<Object> results = tx.exec(); }

事务期间Redis服务端会把命令排队,exec时统一执行。我要强调一点:Redis事务不支持回滚,它只保证“命令全部执行”,如果中间某条命令语法错误,其他命令照样执行。所以在Java侧做好参数校验,别指望Redis帮你回滚。这也是很多老手宁愿用Lua脚本的原因,Lua能保证多条命令的原子性,而且执行期间不会被其他客户端命令插入,逻辑控制也更强。

3.4 Lua脚本:原子性的终极方案

在Java里执行Lua脚本,Jedis的写法:

String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else return 0 end"; try (Jedis jedis = pool.getResource()) { Object result = jedis.eval(luaScript, Collections.singletonList("lock:order:123"), Collections.singletonList("uuid-token")); // 返回1说明删除成功,0说明value不匹配不删 }

这段Lua是分布式锁释放的标准写法:先比对当前值是不是自己设置的唯一标识,是才删除,避免误删别人的锁。用Java原生命令组合也行,但至少两次网络交互无法保证原子性,用Lua一次搞定,永远不会有中间状态。

Lettuce里不光能eval,Spring Data Redis的DefaultRedisScript封得更舒服,这个放到下一章节再说。

4. Spring Data Redis封装之后,三种Template的区别和用法必须吃透

4.1 StringRedisTemplate、RedisTemplate、ReactiveRedisTemplate怎么选

进入Spring环境之后,你基本不会直接跟Jedis或Lettuce打交道了,操作全部经由RedisTemplate及其变体。

StringRedisTemplate是Spring Boot自动配置里最推荐日常使用的。它的key和value的序列化方式默认都是StringRedisSerializer,也就是存进去什么字符串,存到Redis里就是什么字符串,人类可读,排查问题直接redis-cli get就能看到。适合所有key和value都是字符串的场景,比如缓存JSON字符串、计数器、验证码。

RedisTemplate是更通用的模板,默认用的是JdkSerializationRedisSerializer。这个序列化器会把你存入的对象转成Java序列化二进制,可读性为零,还会把类信息写进去,key看起来像\xac\xed\x00\x05t\x00\x03foo这种。好处是能直接存任意对象,坏处是存储体积大、跨语言不友好。我的建议是别用默认的RedisTemplate,要么换成JSON序列化器,要么直接用StringRedisTemplate加手动序列化。

ReactiveRedisTemplate是响应式编程专属,API返回Mono和Flux,适合WebFlux项目。如果你用的是Spring MVC传统模型,没必要硬上这个,学习和排查成本都不低。

4.2 RedisTemplate的序列化器配置,五选一不如就选两种

Spring Boot里,RedisTemplate序列化器的可选项看着多:String、Jackson、Jdk、GenericJackson、Jackson2Json。实际上日常项目里够用的就两种:全String,或者key用String、value用Jackson。

我自己项目的配置这么写的:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key序列化 StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); // value序列化 GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }

这样配置之后,key在Redis里可读,value是JSON格式,跨语言调试也没问题。GenericJackson2JsonRedisSerializer会在序列化时带上@class字段,反序列化时能还原成原来的对象类型。注意如果不想存类型信息,用Jackson2JsonRedisSerializer并手动指定目标类型,省出来的存储空间很可观,但对泛型和多态支持弱一些。

提示:无论选哪种序列化器,改了配置之后一定先做一次读写验证。因为老数据是按旧序列化器写的,换序列化器后读老key会直接反序列化失败,线上切换前做好数据迁移或灰度方案。

4.3 操作Hash、List、ZSet时的类型坑

用RedisTemplate操作Hash类型时,最典型的坑是Hash里的字段序列化器没设置。你如果只设置了key和value的序列化器,没设置hashKeySerializer和hashValueSerializer,默认就落到Jdk序列化,存进去又是乱码。

// 正确姿势 stringRedisTemplate.opsForHash().put("user:100", "name", "zhang3"); stringRedisTemplate.opsForHash().put("user:100", "age", "25"); Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries("user:100");

StringRedisTemplate下Hash的字段名是String,存进去可读。但用RedisTemplate时,如果忘了配Hash序列化器,字段名"name"会被序列化成二进制乱码,查问题时非常崩溃。

ZSet的score字段是double类型,操作排行榜时一般没问题,但要注意浮点精度。比如zdd添加成员的score如果是99.99,Redis内部存的是双精度浮点,多次累加可能出现99.989999999的情况。做排行榜展示时,建议前端拿到分数后做格式化,不要直接展示原始值。

List类型方面,leftPush和rightPush的方向决定了你用List当栈还是当队列。拿List当消息队列用的时候,注意消费端用rightPop并且加超时阻塞,比如rightPop(key, timeout),这样能在没有消息时避免空轮询。

5. Spring Boot环境下的自动配置与自定义扩展,把Redis用顺手

5.1 自动配置都帮你干了什么

Spring Boot的RedisAutoConfiguration会自动创建RedisConnectionFactory、StringRedisTemplate和RedisTemplate。你只要引入依赖并配置连接信息,就能直接注入使用。

自动配置的RedisTemplate默认是RedisTemplate<Object, Object>,value序列化器默认是Jdk。你已经猜到问题了吧——直接用自动配置的RedisTemplate,存入的key是乱码。所以几乎每个项目都要覆盖这个Bean,用一个自己配置的泛型更明确的RedisTemplate<String, Object>替换默认的。上面的RedisConfig配置类就是做这件事。

自定义Bean覆盖自动配置的规则很简单:在配置类里声明同类型Bean,Spring Boot会以自定义Bean优先。但注意Bean方法名最好别叫redisTemplate,叫redisTemplate是故意覆盖,叫myRedisTemplate则保留了默认的,两者共存容易让人分不清注入的是哪个。我建议直接覆盖并取名为redisTemplate,让所有注入点都拿到自定义的那个。

5.2 自己封装一个RedisService,别让业务代码散落一堆opsForXxx

上面那些Template的API用归用,但业务代码里到处opsForValue()、opsForHash()其实非常割裂。我习惯再包一层RedisService,把常用的操作统一收口,顺便加上重试、空值处理、过期策略。

一个精简版的封装思路:

@Service public class RedisService { private final StringRedisTemplate stringRedisTemplate; public RedisService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } public void set(String key, String value, long timeout, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, value, timeout, unit); } public String get(String key) { return stringRedisTemplate.opsForValue().get(key); } public boolean setIfAbsent(String key, String value, long timeout, TimeUnit unit) { return stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); } public void delete(String key) { stringRedisTemplate.delete(key); } public Long increment(String key, long delta) { return stringRedisTemplate.opsForValue().increment(key, delta); } }

这个封装最大的价值不是把API变少,而是把“缓存的读写策略”收敛到了一处。比如你决定所有缓存key统一加业务前缀、统一设置默认过期时间、统一处理值为空的情况,只需要改这个类就够了,业务代码一行都不用动。

5.3 缓存穿透、击穿、雪崩的典型解法,直接放进Service层

这三个是Java后端聊缓存绕不开的话题,我把它们跟RedisService放到一起讲,因为真正落地就在这个封装层。

缓存穿透是指查询一个不存在的key,缓存和数据库都没有,每次请求都打到数据库。常规解法是缓存空值,但空值也要注意过期时间别太长,设个2到5分钟就够。也可以在Service层的get方法里约定,如果查出来是空字符串或特殊占位符,就视为数据库不存在,不再放行到DB查询。

缓存击穿是指某个热点key过期瞬间,大量并发请求同时打到DB。解法是互斥锁重建缓存,或者提前把热点key的过期时间拉长并异步刷新。互斥锁在Service层就能实现,用setIfAbsent当锁:

public String getWithMutex(String key, long expireTime, TimeUnit unit) { String value = stringRedisTemplate.opsForValue().get(key); if (value != null) { return value; } // 尝试获取锁,lockKey用业务key加后缀,value用唯一请求标识 String lockKey = key + ":lock"; String requestId = UUID.randomUUID().toString(); boolean locked = setIfAbsent(lockKey, requestId, expireTime, unit); if (!locked) { // 没拿到锁,短暂sleep后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getWithMutex(key, expireTime, unit); } try { value = loadFromDb(key); // 真正查DB stringRedisTemplate.opsForValue().set(key, value, expireTime, unit); return value; } finally { // 释放锁时校验requestId String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class); stringRedisTemplate.execute(script, Collections.singletonList(lockKey), requestId); } }

这个方案牺牲了一点性能(拿不到锁的请求要等待),但把DB压力控制住了。注意释放锁时用了Lua脚本校验requestId,避免因为处理超时把别人刚获取的锁误删。

缓存雪崩是指大量key同时过期,或者Redis实例宕机导致所有请求打到DB。对策分散:key过期时间加随机值、多级缓存、Redis主从加哨兵。在Service层一步步来,先把过期时间的随机抖动做上,能避免大部分刷屏式击穿。

6. 分布式锁的实现细节与Lua脚本落地

6.1 为什么不用setnx加expire,这个老坑到现在还有人踩

很多人一开始学分布式锁,都写过这种代码:

// 错误示范 Boolean set = stringRedisTemplate.opsForValue().setIfAbsent("lock", "value"); if (set) { stringRedisTemplate.expire("lock", 30, TimeUnit.SECONDS); // 业务逻辑 stringRedisTemplate.delete("lock"); }

问题在于setIfAbsent和expire是两条命令。如果第一条执行成功、第二条还没执行时进程崩溃或网络断开,这个锁就没有过期时间,直接变成死锁。为什么每次讲Redis分布式锁都要强调这个?因为线上确实有人这么写,锁永远不会释放,直到人工干预。

正确做法是把setIfAbsent和过期时间合并成一条原子命令:

stringRedisTemplate.opsForValue().setIfAbsent("lock:order:123", requestId, 30, TimeUnit.SECONDS);

Spring Data Redis里的setIfAbsent(K key, V value, long timeout, TimeUnit unit)就是原子操作,底层走的就是SET key value NX EX timeout,一次网络请求搞定。

6.2 锁的过期时间怎么定,续期怎么做

锁的过期时间设太短,业务还没执行完锁就没了;设太长,万一持有锁的节点挂了,其他节点要等很久。折中方案是看业务耗时设置,比如业务平均耗时200ms,锁过期时间设5秒,已经留了20多倍余量。

但总有极端情况,比如慢GC、IO阻塞导致业务执行超过锁过期时间。这种情况我推荐用续期机制。最简单的续期是起一个守护线程,每隔三分之一过期时间检查一次,如果业务还没结束就执行expire续期。业界有现成的Redisson的watch dog就是这个逻辑,默认30秒过期,每10秒续一次。

自己实现续期也不复杂:

ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); ScheduledFuture<?> renewTask = executor.scheduleAtFixedRate(() -> { stringRedisTemplate.expire(lockKey, expireTime, TimeUnit.SECONDS); }, 10, 10, TimeUnit.SECONDS);

关键点在于:续期任务一定要在finally里cancel掉,否则锁释放了线程还在继续续一个不存在的key,白白增加Redis压力。而且线程池建议做成静态共享,不要每个锁操作都new一个。

6.3 Redisson的RLock到底比自研强在哪

如果有人问我,分布式锁到底自己写还是直接用Redisson,我的答案是:中小项目可以自己写,但涉及高并发、需要可重入、需要等待锁、需要公平锁的场景,直接用Redisson。

Redisson的RLock天然支持可重入,同一个线程可以多次加锁不会死锁;支持tryLock(waitTime, leaseTime, TimeUnit),拿不到锁时等待指定时间,而不是直接返回失败;底层续期逻辑已经内置,不用自己写守护线程。

RLock lock = redissonClient.getLock("lock:order:123"); boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑 } finally { lock.unlock(); } }

注意tryLock第一个参数是等待时间,第二个是锁自动释放时间。如果传了leaseTime,Redisson不会开启续期;如果不传,默认会启动watch dog续期。用unlock()释放锁时,Redisson内部也会做身份校验,比手写Lua省心。

7. 序列化方案的完整对比,以及我对乱码问题的最终解法

7.1 从乱码现场反推序列化器配置

“存进去明明是字符串,查出来变成\xac\xed开头的一串”这个问题,在Spring Boot + Redis项目里出现的频率超高。根因只有一个:key或value的序列化器用成了Jdk。

\xac\xed是Java序列化魔法头,\x00\x05是版本号,看到这个就说明当前读写用的序列化器和当初写入时的序列化器不一致。排查链路一般是这样的:

第一步,redis-cli登录Redis,用type key看类型,用get key看值。如果值是人类可读的字符串,说明写入端用的是String序列化。如果值是一串带\xac\xed开头的二进制,说明写入端用了Jdk序列化。

第二步,回代码里看当前读取用的Template是哪一种。如果注入的是RedisTemplate<Object, Object>且没自定义序列化器,那你读的时候默认就是Jdk去反序列化Jdk格式的数据,如果写入端是String格式,必然失败或者乱码。

第三步,统一改配置。全项目只保留StringRedisTemplate和自定义的RedisTemplate<String, Object>,所有key强制String序列化。这是最不容易出错的组合。

7.2 对象缓存怎么存?JSON字符串还是JDK序列化对象

对象缓存的存储格式选择,直接决定后续的调试体验和跨语言支持程度。

我的建议是存JSON字符串,理由有三个:可读,redis-cli直接能看内容;跨语言,Java写、Python读、Node读都行,只要JSON结构对;灵活,字段增减只影响读取端解析,不必强制所有实例都升级。

具体操作思路:业务对象先转JSON,再存入StringRedisTemplate;读取时取出JSON字符串,再用ObjectMapper或Gson转回对象。你可以把这段逻辑封装成两个工具方法,比如setJson(key, obj, expireTime)和getJson(key, clazz)。

如果确实要用RedisTemplate直接存对象,序列化器就用GenericJackson2JsonRedisSerializer。但有一点要记住:反序列化时GenericJackson2JsonRedisSerializer依赖对象有默认构造函数,如果对象没有无参构造器,反序列化直接报错。这个坑很隐蔽,建议所有缓存对象都保留一个无参构造方法。

7.3 序列化器选型对照表

序列化器key可读性value可读性跨语言存储体积推荐场景
StringRedisSerializer完全可读完全可读好小key和value都是字符串
JdkSerializationRedisSerializer乱码乱码差大基本不推荐
GenericJackson2JsonRedisSerializer可读JSON可读好中存对象且要保留类型信息
Jackson2JsonRedisSerializer可读JSON可读好小存对象,明确类型
Kryo(第三方)不可读不可读一般极小追求极致存储压缩,不在意可读性

在线下跟我合作过的一个项目组,为了省内存把用户缓存对象用了Kryo序列化,结果联调时前端同学要查缓存内容,redis-cli完全看不出来里面是什么。后来还是换回GenericJackson。我的态度是:除非内存紧张到需要省那几十个字节,否则别为压缩牺牲可读性。

8. 实际项目里Redis操作最容易翻车的五个场景

8.1 慢查询:大Key和批量操作引发的Redis阻塞

Redis是单线程执行命令,一个慢命令会让后续所有命令排队等待,临床表现就是“某个时刻所有接口突然变慢”。最常见的慢命令是KEYS、SMEMBERS、HGETALL,尤其是大Key。

KEYS pattern为什么危险?它需要遍历整个键空间,数据量大时直接阻塞Redis。生产环境禁用,要查Key用SCAN:

ScanOptions options = ScanOptions.scanOptions().match("user:*").count(100).build(); Cursor<String> cursor = stringRedisTemplate.scan(options); while (cursor.hasNext()) { String key = cursor.next(); // 处理key } cursor.close();

HGETALL对大Hash也一样,几千个字段一次性全部返回,序列化和网络传输都要时间。优化方式是拆Hash,把大Hash按业务维度拆成多个小Hash,或者用HSCAN分批取。千万级别的数据,慢查询一旦出现,从日志里看命令耗时几百毫秒,整个Redis实例的影响面非常大。

8.2 大Value:为什么单个Value超过10KB要警惕

单个Value过大,除了拖慢网络传输,还容易引发内存碎片化和持久化开销。Redis做RDB快照时会fork子进程,大Key导致内存页表复制量变大,复制期间Redis阻塞时间变长。

我的建议,常规缓存Value控制在10KB以内。如果业务确实需要存大于100KB的JSON,优先考虑拆分存储、压缩存储或者换到专门的存储系统。也别迷信压缩,压缩CPU开销不低,查询频率高的场景得不偿失。

8.3 Key命名规范:冒号分隔比纯拼接更值得推广

Redis的Key命名,业界通用的建议是业务:实体:ID的冒号分隔法,比如user:profile:10086、order:status:20250101001。好处有三条:可读性强,一眼看出业务归属;支持SCAN按前缀匹配;便于在Redis Desktop Manager这类工具里按前缀浏览分组。

反面教材是把所有Key揉成一个无意义的长字符串,比如userprofile10086或者userId10086Date20250101。这种Key没法按业务归类,维护时为了找一个Key只能全量扫,非常痛苦。

8.4 过期策略不统一:有的Key永不过期,有的刚写就过期

Teammates写缓存的时候最随意的地方就是过期时间。同一个接口的缓存,有人设1小时,有人设5分钟,还有人压根不设。线上内存增长快,往往就是一堆没有过期时间的“永久Key”堆出来的。

我的做法是定义公共常量类:

public class CacheTime { public static final long GENERAL = 30L; // 30秒 public static final long SHORT = 5L; // 5分钟 public static final long MIDDLE = 30L; // 30分钟 public static final long LONG = 24L * 60; // 1天 }

所有地方引用常量,不同业务按实际频率选档位。这样既能避免“刚写就过期”,也能避免“永久不删”。同时,定期用命令统计没有TTL的Key,比如redis-cli里可以用scan结合ttl检查,发现业务上不该永久的Key,及时补上过期时间。

8.5 连接泄漏:用完的连接没还回去

无论是Jedis还是Lettuce,连接资源必须规范收放。Jedis如果用了连接池,try-with-resources或finally里调用close(),close()不是物理断开,而是归还连接池。忘了调用,池里的连接慢慢被借光,后面请求全部超时。

Lettuce的StatefulRedisConnection也要在应用关闭时调用close()。Spring环境下用RedisTemplate一般不用担心,Spring容器会管理连接工厂生命周期;但如果你在工具类里手动connect(),就要自己负责关闭。

提示:每次改动Redis连接相关的代码后,看两个指标:一是Redis实例的connected_clients,正常波动平稳;二是Java进程的TCP连接数,如果在缓慢上升,大概率有连接没释放。

9. 多环境配置与Redis运行状态监控,最后聊点能落地的

9.1 多环境配置的差异化管理

开发、测试、生产的Redis参数通常不一样。开发环境可以不用密码,生产必须加密码,并且建议开启ACL做最小权限授权。Spring Boot多环境配置用配置文件区分即可:

# application-dev.yml spring: redis: host: localhost port: 6379 database: 0
# application-prod.yml spring: redis: host: redis-prod.internal port: 6379 password: ${REDIS_PASSWORD} # 密码放环境变量里 database: 2 lettuce: pool: max-active: 64

密码写在配置文件里有个风险,配置文件一旦泄露,所有环境Redis密码全部暴露。我建议密码全部从环境变量或配置中心读取,本地开发可以通过.env文件注入,生产从部署平台的环境变量注入。

9.2 INFO命令能告诉你的六个关键信息

排查Redis性能问题,我最先用的命令是INFO。里面有几个指标价值非常高:

connected_clients:当前连接数,异常升高说明应用侧可能连接泄漏或者并发突发。used_memory和used_memory_rss:前者是逻辑内存,后者是实际占用物理内存,两者差距过大多半是内存碎片。keyspace_hits和keyspace_misses:缓存命中率,命中率骤降说明缓存大量失效或者业务key改造。evicted_keys:内存淘汰的key数量,激增说明内存不够了或者过期时间设置不合理。latest_fork_usec:上次RDB快照fork耗时,数值超过几秒说明大Key问题严重。instantaneous_ops_per_sec:当前QPS,初步判断实例负载。

Java项目里可以用RedisTemplate的execute拿到连接后执行info,或者直接集成Spring Boot Actuator的Redis健康检查。日常最方便的还是在Redis命令行工具里redis-cli info一条条看,或者接上监控面板自动采集。

9.3 压测时Redis连接数为什么突然暴涨,怎么定位

某个下午,A同学跑完一轮压测,发现Redis的connected_clients从几十涨到了几千。第一反应是“是不是连接池没关”,打开代码一看,连接池配置都正常。再看日志,发现每个请求都在从连接池获取连接后没有释放。

还有一个经常被忽略的点:如果使用了Lettuce,Spring Data Redis在异步操作时可能会额外创建连接,这些连接如果没有被复用池管理,数量会随并发线性增长。解决办法是确认spring.redis.lettuce.pool参数生效,同时限制异步并发数,必要时把异步操作改成同步阻塞,压测数据会更可预期。

定位这一类问题,我的排查顺序是:先看当前活跃连接数,再对Java进程做线程dump,找到“拿着Redis连接不放”的线程栈,最后对照代码定位遗漏close()的逻辑。线程dump这一步最有效,尤其能定位客户端连接泄漏。

10. 最后分享一个我习惯的实践顺序

从零开始在一个Spring Boot项目里接入Redis,我建议按这个顺序走:先在本地跑通Jedis直连,确认对Redis命令本身的操作没问题;再把Jedis换成Lettuce,感受连接复用的写法和异步API;接着引入Spring Data Redis,用StringRedisTemplate完成基本读写,覆盖默认序列化器;然后把对象缓存和分布式锁纳入自封装的RedisService;最后统一配置多环境参数、监控指标和压测验证。

这套顺序是我自己踩了很多次坑之后顺出来的,它能保证每一步的变量只有几个,出了问题容易定位。如果你预算有限只做三个动作,那我建议是:序列化器全部换成String或JSON、连接池参数按业务调过、分布式锁用原子命令加Lua释放。这三样做了,项目里Redis的稳定性就已经超过大部分“能跑就行”的系统了。

iPad按键声音关闭的技巧:如何通过快捷设置与辅助功能实现静音操作 嘿,各位数码控和iPad重度用户,今天咱们来聊聊一个看似不起眼,实操起来却能让幸福感直线上升的小功能——关闭iPad的按键声音。如果你跟我一样,喜欢在深夜窝在被窝里刷剧、打游戏、翻网页,那每次敲击屏幕发出的“嗒嗒”声,简直就像在安静的图书馆里大声嚼薯片,不仅自己觉得吵,还容易打扰到身边人。

先别急着划走,这篇文章不只是教你怎么关掉那个键盘打字声,而是会从快捷设置、系统设置到辅助功能,把iPad上所有跟“按键声音”相关的开关一次性给你捋清楚,包括哪些机型支持、哪些设置之间会互相影响、以及几种实际场景下的最优解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询