我做了几年Java开发,Redis基本成了每个项目里都绕不开的组件。缓存、分布式锁、排行榜、计数器、消息队列、甚至限流都能看到它的影子。很多刚接触的同学会以为Redis就是拿来存缓存、存个Session,实际用起来才发现里面的门道不少,而且每个场景在真实业务里都有对应的坑。这篇就把我日常在Java项目里常用到的Redis场景和方法整理一下,重点是能落地的那部分,包括代码怎么组织、参数怎么调、出问题怎么查。
1. 缓存加速:最基础也是最容易踩坑的场景
缓存是Redis使用频率最高的场景。把热点数据(比如用户信息、商品详情、配置信息)放到Redis,能明显降低数据库压力,接口响应速度也能快一个数量级。但缓存这事,写进去容易,要保证业务不出问题,背后还有一堆细节。
1.1 业务里哪些数据适合做缓存
不是所有数据都适合往Redis里塞。适合缓存的数据有几个明显特征:读多写少、单个数据量不大、一致性要求不是极致、数据变更频率低或者能容忍秒级延迟。典型的像商品详情、用户基础信息、配置项、公告内容。不适合的直接放Redis的也有,比如频繁更新且强一致的数据、订单金额这种账务类数据、跨多个维度实时聚合的报表。
我自己的经验是,做缓存设计之前先盘一下业务读写比。如果一个接口90%以上是查询,只有10%是更新,那这个数据就非常适合用缓存来扛。反过来,如果每次更新都要求所有查询立刻看到最新值,那引入缓存反而增加了麻烦,缓存失效和穿透的问题会一直追着你。
另外Redis缓存的数据尽量不要太大。我见过有人把整张几十M的图片base64字符串塞进Redis做缓存,内存压力特别大,网络传输也慢。一般单个缓存value控制在几KB以内比较合理,大的数据要拆开存或者换其他方案。
1.2 缓存的过期策略怎么定
设置过期时间是个技术活。常见的做法是写入时指定一个TTL,比如商品信息缓存5分钟、配置缓存10分钟。关键点在于过期时间不要设成固定值,否则容易造成大量key在同一时刻过期,流量一起打到数据库上,形成雪崩。
可以采用一个基础过期时间加上随机偏移量的做法。例如基础过期时间是300秒,实际设置成300到360秒之间的随机值,用代码实现就是:
long baseTtl = 300; long randomTtl = baseTtl + ThreadLocalRandom.current().nextLong(60); redisTemplate.opsForValue().set(key, value, randomTtl, TimeUnit.SECONDS);这样每个key的过期时间错开,集中在某个时间点同时过期的概率就大大降低了。
1.3 缓存穿透、击穿、雪崩的应对
这三个问题是缓存场景里的经典“三座大山”,面试常问,业务里也真的是高发问题。
缓存穿透指查询一个不存在的数据,缓存里没有,数据库里也没有,每次请求都打到数据库。如果有人在恶意构造这种请求,数据库可能直接被拖垮。应对思路有几个:
- 空值也缓存,设置短一点的TTL,比如60秒,减轻数据库压力
- 用布隆过滤器,在请求进来时先判断这个key是否可能存在,不存在直接返回
- 对单个IP或用户的异常高频访问做限流
缓存击穿指一个热点key在过期的瞬间,大量请求同时进来发现没有缓存,各个线程都去查数据库,把数据库打爆。应对办法常用互斥锁,也就是拿到锁的线程才去查数据库并回填缓存,其它线程短暂等待后再读缓存:
String value = redisTemplate.opsForValue().get(key); if (value == null) { String lockKey = "lock:" + key; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); if (locked) { try { value = queryFromDb(); redisTemplate.opsForValue().set(key, value, 300, TimeUnit.SECONDS); } finally { redisTemplate.delete(lockKey); } } else { // 拿不到锁就先短暂休眠再重试 Thread.sleep(100); value = redisTemplate.opsForValue().get(key); } }这种做法逻辑上不复杂,但要注意锁必须有超时时间,防止拿到锁的线程崩溃后锁一直不释放。
缓存雪崩指大量key同一时间失效,直接拖垮数据库。除了上面说的过期时间加随机值,还可以在应用层做本地缓存兜底,Redis失效时先读本地,本地没有再去数据库。本地缓存用比如Caffeine,设置缓存比例和刷新机制,Redis挂了也能撑一段时间。
注意:真实项目里如果数据库压力大,别只依赖Redis这一层,合理的做法是Redis + 本地多级缓存 + 数据库连接池限流,形成一条完整的保护链条。
2. 分布式锁不止一种玩法
分布式环境下多个节点操作同一份数据,光靠数据库或者本地锁解决不了问题。Redis因为有单线程执行命令的特性,天然适合做分布式锁的载体。
2.1 单体锁为什么在分布式环境失效
以前单机应用用synchronized或者ReentrantLock,只能在同一个JVM内保证互斥。微服务架构下多个实例部署在不同机器上,A实例加的锁B实例根本感知不到,两边同时操作同一个资源就会出问题。分布式锁的目的就是让所有实例用同一个“锁服务”来协调,谁抢到谁干活。
Redis实现分布式锁的核心命令是SETNX,意思就是如果key不存在才设置成功。多个客户端同时去设置同一个key,只有一个能成功,这就完成了加锁。
2.2 正确实现一个Redis分布式锁
最简单的实现方式:
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, expireTime, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑 } finally { // 释放锁,先比较再删除 String value = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(value)) { redisTemplate.delete(lockKey); } } }这里有两个容易犯的错误。第一,SETNX和EXPIRE必须是一条命令完成,否则中间redis重启或者网络异常,key就没有过期时间,锁永远不释放。现代Redis在SET命令上可以直接带NX、EX参数,或者使用Spring Data Redis提供的setIfAbsent(K key, V value, Duration timeout)方法,底层是一条原子命令,不会有问题。
第二,释放锁时不能直接delete,要用一个自己生成的唯一标识(比如UUID)作为value,先比较value是不是自己设置的,相同才删除。不然可能出现这种情况:A获取锁后业务执行时间过长,锁自动过期了,B获取了同样的锁,A业务执行完后直接删除,把B的锁也删了,锁就失效了。先比较再删除这个操作本身要用Lua脚本来保证原子性:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end2.3 Redisson的封装与自动续期
实际项目里我不太建议自己手写分布式锁,除非需求非常简单。生产环境我一般直接用Redisson,它提供了开箱即用的可重入锁,还解决了锁自动续期的问题,也就是常说的看门狗机制。
Redisson的用法:
RLock lock = redissonClient.getLock("order:pay:lock"); boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { try { // 处理分布式业务 } finally { lock.unlock(); } }底层它默认情况下,如果没指定leaseTime,会启用看门狗自动续期,默认续期时间是30秒,业务还没执行完时自动把锁的过期时间延长,避免业务未结束锁就提前失效。指定了leaseTime则不会自动续期。这个机制对执行时间不确定的业务特别有用。
分布式锁还有一层问题是主从切换时可能丢锁。比如主节点写入了锁但还没同步到从节点,主节点宕机,从节点顶上,锁数据丢了,另一个客户端就能拿到同一把锁。这种极端场景适合用RedLock方案,但RedLock在工程里的争议也不少。我的观点是,大部分业务场景用单实例Redis加自动续期就够了,要求极高一致性的系统需要重新评估架构,而不是盲目套RedLock。
3. 用ZSet和String做排行榜与计数器
排行榜和计数器是Redis在业务里很高频的用法,而且实现起来非常简洁,数据结构用得恰到好处,很多项目都用它来做排名和统计。
3.1 排行榜的排序与分页读取
要实现文章热度榜、积分榜、销售额排行,直接想到的就是Redis的ZSet。ZSet每个成员有一个分数,Redis按分数自动从小到大排序,正序用ZRANGE,倒序用ZREVRANGE。
Java里用Spring Data Redis操作:
// 增加用户积分 redisTemplate.opsForZSet().incrementScore("rank:user:points", userId, 10); // 获取前20名 Set<ZSetOperations.TypedTuple<Object>> top20 = redisTemplate.opsForZSet() .reverseRangeWithScores("rank:user:points", 0, 19); // 获取用户当前排名,排名从0开始 Long rank = redisTemplate.opsForZSet().reverseRank("rank:user:points", userId);排行榜如果数据量大,分页越往后性能越差,因为Redis内部是跳表实现,区间查询复杂度在O(logN+M)。一般排行榜只需要展示前100或者前1000名,这个区间内性能没有问题。如果想要全部排名,可以把榜单快照定时同步到数据库或者文件供导出。
排行榜场景还有一个细节是并列排名。Redis的ZSet的分值一样时,谁先加入谁排在前面,如果需要“分数一样则按达成时间排序”,可以把分值和时间的组合拆分成一个综合分数,比如score = 实际分数 * 100000 + (最大时间戳 - 当前时间戳),这是项目中常用的技巧。
3.2 计数器的原子自增与过期窗口
文章阅读量、视频播放量、点赞数等都适合用Redis的String配合INCR命令来实现。INCR是原子操作,多个客户端并发调用不会出现计数丢失。
典型代码:
Long count = redisTemplate.opsForValue().increment("article:view:123");如果要求某段时间内的计数,比如一天内的访问量,可以给key设置过期时间,第二天自然清零。注意每次INCR后,如果忘了设置过期时间,可以结合EXPIRE配合使用,或者初始化时提前设置好TTL,让新的一天生成一个新key。
计数场景里要注意的是双写一致性。Redis里的计数最终要落到数据库,可以采取定时刷盘策略:比如每隔5分钟把增量同步到数据库,然后清零或者记录上一次的累计值。这个方法能明显降低高频计数对数据库的写压力。
我实际用下来的习惯是:热点计数全部先走Redis,后台任务一分钟或者五分钟批量刷一次全量计数到数据库,同时把Redis里的计数重置或保留,这样数据库不会成为性能瓶颈。
4. 消息队列与异步解耦的轻量方案
很多人一听消息队列就想到MQ中间件,其实Redis也提供了队列能力。在体量不大、并发要求没到必须引入独立中间件的时候,Redis的List和Stream已经是够用的方案。
4.1 用List实现简单的任务队列
List的命令天然适合做队列:LPUSH从左边放任务,BRPOP阻塞等待从右边取任务。这种生产-消费模式在Java里可以封装成一个简单的队列服务:
// 生产者 redisTemplate.opsForList().leftPush("queue:email:send", emailTask); // 消费者 while (true) { Object task = redisTemplate.opsForList().rightPop("queue:email:send", 5, TimeUnit.SECONDS); if (task != null) { handleTask(task); } }BRPOP的阻塞时间要注意,传0会一直阻塞直到有数据,传具体时间比如5秒则超时返回。实际生产里一般设一个几秒的超时,超时后循环继续,避免某条连接被长时间占用降低吞吐。
List队列有一个可靠性问题:任务被消费者取出后,如果消费者在执行业务时宕机了,这个任务就丢了,没有ack机制。如果业务要求至少处理一次,需要自己做补偿读或者用Stream。另外List队列的消息不会被重复消费阻塞,多个消费者各自取各自的任务,天然实现了负载均衡。
4.2 用Stream实现可回溯和消费组
Redis 5.0引入的Stream数据结构,是更接近现代消息队列的实现方式。它支持消息ID、消费组、确认机制,消息可以回溯,还能做消息持久化。对于不想引入重型中间件、又需要消费确认的场景,Stream是很好的替代方案。
演示一个简单的消费组流程:
// 创建消费组,从最早的ID开始消费 redisTemplate.opsForStream().createGroup("queue:stream:order", "group:order"); // 消费者读取,参数为消费组名、消费者名、读取数量、阻塞时间 List<MapRecord<Object, Object, Object>> records = redisTemplate.opsForStream() .read(Consumer.from("group:order", "consumer-1"), StreamReadOptions.empty().count(10).block(Duration.ofSeconds(2)), StreamOffset.create("queue:stream:order", ReadOffset.lastConsumed())); // 处理完后确认 for (MapRecord<Object, Object, Object> record : records) { handleRecord(record); redisTemplate.opsForStream().acknowledge("queue:stream:order", "group:order", record.getId()); }Stream的消费组保证每个消息只发给一个消费者,消费后要主动Ack,否则消息会留在pending列表里。配合XPENDING和XCLAIM可以处理未确认消息的转移和二次投递。
不过也要说实话,Redis Stream在功能上和专业MQ还是有差距。比如延时消息、事务消息、灵活的路由规则都没有。业务一旦复杂起来,还是老老实实上专门的消息队列,Redis Stream只适合体量可控、逻辑简单的场景。
5. 布隆过滤器与限流:高并发场景的两个实战技巧
除了日常的缓存和队列,Redis在高并发场景下还有两个很实用的用法,一个是防止缓存穿透的布隆过滤器,一个是接口限流。这两个方案都要核心借助Redis的高性能特性来保证在流量洪峰时顶得住。
5.1 用布隆过滤器拦截不存在的请求
布隆过滤器是一种概率性数据结构,它可以非常高效地判断一个元素“一定不存在”或者“可能存在”。用它来挡并发请求里大量查询不存在的ID非常有效。
Java里用Redisson的RBloomFilter:
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("bloom:user:id"); bloomFilter.tryInit(1000000L, 0.01); bloomFilter.add("userId123"); boolean mightContain = bloomFilter.contains("userId123");tryInit两个参数,第一个是预期元素数量,第二个是误判率。误判率设置越小,需要的空间越大。对大多数业务场景,误判率0.01到0.001已经够用。布隆过滤器的误判只可能导致极少数本不存在的请求穿透到数据库,而真正的“一定不存在”会被有效拦截。
布隆过滤器不能删除元素,如果要删除更新场景,可以定期重建过滤器或者维护一份包含有效ID的缓存列表。另外布隆过滤器在Redis重启后会丢失,需要重新初始化,因此要设计好在应用启动时把存量数据批量加载进去。我在某些项目里使用了一个简单策略:启动时扫描数据库里最近90天的业务ID写入过滤器,历史更早的ID直接忽略,因为被查询的概率极低。
5.2 基于Lua脚本实现接口限流
接口限流常见的有令牌桶、漏桶、固定窗口、滑动窗口几种算法。Redis实现限流最常用的方式是滑动窗口计数,借助ZSet记录每个时间窗口内的请求,判断请求数是否超过阈值。
一个基于Lua脚本的限流方案可以这么实现:
local key = KEYS[1] local window = tonumber(ARGV[1]) local maxCount = tonumber(ARGV[2]) local now = redis.call('TIME')[1] * 1000 redis.call('ZREMRANGEBYSCORE', key, 0, now - window) local count = redis.call('ZCARD', key) if count >= maxCount then return 0 end redis.call('ZADD', key, now, now .. '_' .. math.random(100000)) redis.call('PEXPIRE', key, window) return 1调用方每次请求执行这段脚本,返回1表示放行,返回0表示被限流。用Lua的原因是一整个判断和写入过程是原子的,不会有两个请求并发时同时通过检查。
Java侧的调用封装:
String luaScript = "...加载上面的Lua..."; DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class); Long result = redisTemplate.execute(script, List.of("rate:user:" + userId), String.valueOf(windowMillis), String.valueOf(maxRequests)); if (result != null && result == 1L) { // 允许请求进入 } else { // 返回忙或直接拒绝 }这个方案限流粒度可以很细,按用户、按IP、按接口维度分别设置key。需要注意ZSet里的members用的是时间戳_随机数拼出来的值,目的是避免同一个毫秒内多个请求写入时member重复,导致ZADD覆盖旧数据、计数不准。这个细节很隐蔽,我第一次自己写的时候没注意,结果限流很快就不准确了。
6. 常见问题与排查技巧实录
用了那么多年Redis,踩过的坑确实不少。这里挑几个典型问题记录一下排查过程和处理经验,基本上都是生产环境零散总结出来的。
6.1 缓存与数据库的一致性问题
先更新数据库还是先更新缓存,这个顺序问题几乎是每个项目都会遇到的。
最常用的方案是Cache Aside模式:读的时候先读缓存,没有则读数据库并回填;写的时候先更新数据库,然后删除缓存,让下次读取时重新加载。
这个方案里“先删缓存再更新数据库”会有窗口问题:一个线程删了缓存,另一个线程读到旧值回填缓存,之后数据库更新了但缓存还是旧值。因此更稳妥的流程是“先更新数据库,再删除缓存”。删除失败的话,可以引入延迟双删或者订阅数据库变更事件(binlog)再异步删缓存。
延迟双删简单示例:
// 更新数据库 updateDb(entity); // 删除缓存 redisTemplate.delete(cacheKey); // 延迟再删一次,覆盖并发期间写入的旧缓存 scheduledExecutor.schedule(() -> redisTemplate.delete(cacheKey), 500, TimeUnit.MILLISECONDS);延迟时间根据业务容忍程度调整,一般是几百毫秒。这个方案能解决大部分并发脏读问题,但也不是百分之百完美。追求强一致性的数据,不建议用缓存,或者说缓存只承担加速作用,业务核心数据仍然以数据库为准,容忍短暂不一致。
6.2 大Key和热Key问题
Redis里单个key的value过大就是大Key。大Key会造成读写延迟高、网络带宽占用大、甚至阻塞Redis。常见的大Key是存了很大集合的value、几百万成员的Set、或者一个大的String。排查方法是使用--bigkeys命令或者扫描Redis中的数据大小。
热Key是指某个key的访问特别集中,比如热点商品、抽奖秒杀商品,单一分片上的压力过大。处理手段包括:热点key做本地缓存、复制多份缓存key随机读取(比如每个节点用不同的key后缀)、以及限流保护。
我遇到过最夸张的一次,某公司为了统计一个活动数据,把一个List里塞了几十万条消息,每次读取都返回全部列表,Redis主线程直接被卡住,大量命令超时。解决思路很简单:改成按时间分片存储,使用ZSet按ID或者时间范围取需要的批次,不要再一次性拉全量。
6.3 常用命令与监控自查
线上出问题后,我一般先看这几个指标和命令:
redis-cli info查看内存使用、连接数、命中率redis-cli monitor观察实时命令,定位哪些命令比较重redis-cli --bigkeys扫描大Key,注意耗时,尽量在低峰期执行slowlog get 20查看慢查询日志,单个命令执行超过阈值会记录在这里
配置方面,内存淘汰策略maxmemory-policy要提前定。生产环境常用allkeys-lru或者volatile-lru,尽量避免默认的noeviction导致写入失败。
重点:不要在生产环境随意执行
KEYS命令,它会阻塞整个Redis实例。要扫描key的话用SCAN,它是游标迭代方式,不会一次性把所有key拉出来。
写在最后
Redis在Java项目里能做的事远比“一个缓存”多得多,从缓存加速到分布式锁、排行榜、计数器、队列再到限流和过滤器,它几乎覆盖了后端业务开发中的一大半基础需求。每次用Redis解决一个新场景时,我都会把这几个要素想清楚:数据结构的选型是否合适、过期策略是否覆盖了边界、并发下是否有竞态条件、宕机后是否影响主流程。
我个人的建议是,刚开始可以先用场景来驱动学习,比如做一个带点赞和排行榜的小项目,尝试用ZSet、String完成所有统计需求;接着做一个分布式环境下的接口幂等和锁控制,把Lua脚本用熟练。只要这几个场景摸透了,日常业务里能靠Redis解决的问题基本都不会被难住。后面等你开始关注稳定性、性能和成本了,自然而然就会深入到内存淘汰策略、主从复制、集群部署和Sentinel高可用等更深层的话题上面去。