摘要
Redis 是 Java 后端项目中非常常见的基础组件。它可以用作缓存、分布式锁、限流器、会话存储、排行榜、消息队列和临时状态存储,但不同场景对数据一致性、持久化、过期策略和故障恢复的要求并不相同。
很多项目接入 Redis 后,系统确实变快了,却也引入了新的问题:缓存与数据库不一致、缓存穿透、缓存击穿、缓存雪崩、热 Key、大 Key、锁误释放、序列化不兼容、内存突然上涨和集群扩容失败。
本文从 Java 项目中最常见的 Redis 使用场景出发,介绍 Redis 的数据结构、缓存旁路模式、分布式锁和限流实现,并通过 Spring Boot 示例说明如何设计 Key、设置过期时间、处理缓存异常和监控生产风险。读完本文后,你应该能够:
- 判断 Redis 适合解决哪类问题;
- 使用 Cache Aside 模式构建可靠缓存;
- 理解缓存穿透、击穿和雪崩的区别;
- 编写一个基本的分布式锁和限流器;
- 避免序列化、Key、TTL 和大对象等常见坑;
- 建立 Redis 上线前后的基础检查清单。
一、背景与问题
1. 为什么需要 Redis
数据库适合持久化存储和复杂查询,但在高并发场景中,所有请求都直接访问数据库会带来压力:
大量请求 -> 重复查询相同数据 -> 数据库连接池被占满 -> CPU、磁盘和锁竞争升高 -> 接口延迟增加
如果某些数据读多写少、允许短时间缓存,就可以将热点数据放入 Redis:
第一次请求 -> 查询数据库 -> 写入 Redis -> 返回结果 后续请求 -> 直接读取 Redis -> 减少数据库访问
Redis 基于内存提供较低访问延迟,但它不是数据库的简单替代品。是否可以把数据只放在 Redis 中,要看数据是否允许丢失、是否需要事务、是否需要复杂查询以及故障后能否恢复。
2. Redis 常见使用场景
| 场景 | 典型数据 | 主要关注点 |
|---|---|---|
| 缓存 | 商品详情、配置、用户信息 | 一致性、过期和击穿 |
| 分布式锁 | 库存、任务、定时作业 | 唯一标识、续期和释放 |
| 限流 | 接口、用户、IP | 时间窗口、原子性和降级 |
| Session | 登录态、临时会话 | 过期、共享和安全 |
| 排行榜 | 积分、热度、成绩 | ZSet、更新频率 |
| 计数器 | 浏览量、点赞量 | 原子递增和持久化 |
| 消息 | Stream、延迟任务 | 消费、重试和堆积 |
| 分布式状态 | 验证码、幂等 Token | TTL、隔离和删除 |
一个 Key 可能同时承担多个职责,但最好不要让同一个数据既被当作缓存,又被当作强一致业务事实使用。
3. Redis 带来的新风险
引入缓存之后,系统从一份数据变成了两份数据:
数据库:权威数据 Redis:加速副本
这会产生新的问题:
- 数据什么时候写入缓存;
- 数据修改后如何失效;
- 缓存失效时是否会打爆数据库;
- Redis 重启后数据是否还在;
- 缓存内容是否包含敏感信息;
- Redis 故障时应用是否还能提供服务。
缓存优化的本质不是“把查询放到 Redis”,而是建立一套可接受的一致性和故障策略。
二、核心概念
1. Redis 的数据结构
常见数据结构如下:
| 类型 | 特点 | 常见场景 |
|---|---|---|
| String | 字符串或二进制值,支持计数 | 缓存、Token、计数器 |
| Hash | 字段和值的集合 | 对象属性、用户状态 |
| List | 有序列表 | 简单队列、最新消息 |
| Set | 无序去重集合 | 标签、共同关注、去重 |
| Sorted Set | 带分数的有序集合 | 排行榜、延迟任务 |
| Stream | 持久化消息流 | 消费者组、事件处理 |
| Bitmap | 位操作 | 签到、布尔状态 |
| HyperLogLog | 近似去重计数 | UV 统计 |
选择数据结构时,不要只看“能不能存”,还要考虑:
- 查询和更新方式;
- 单 Key 数据量;
- 是否需要持久化;
- 是否需要分片;
- 是否需要批量操作;
- 是否允许丢失;
- 如何监控和清理。
2. Cache Aside:缓存旁路模式
最常用的缓存模式是 Cache Aside:
读流程:
查询缓存 -> 命中:直接返回 -> 未命中:查询数据库 -> 写入缓存 -> 返回结果
写流程:
更新数据库 -> 删除缓存
更新数据库后删除缓存,而不是直接更新缓存,通常可以减少复杂的双写逻辑。读取请求在缓存失效后会重新加载数据库数据。
但这不是绝对强一致方案。并发读写下仍然可能出现短暂不一致,需要根据业务接受程度选择延迟双删、消息失效、订阅数据库变更或其他策略。
3. TTL:过期时间
缓存通常需要设置 TTL,避免:
- 数据永久占用内存;
- 旧数据无限期返回;
- Redis 内存逐渐耗尽;
- 业务数据更新后无法自然恢复。
TTL 的选择取决于:
- 数据更新频率;
- 数据允许过期多久;
- 数据库承载能力;
- 缓存重建成本;
- 是否存在热点访问。
大量 Key 使用完全相同的 TTL,可能导致它们在同一时间集中失效。因此常见做法是给 TTL 加随机抖动:
实际 TTL = 基础 TTL + 随机时间
4. 缓存一致性
常见写入顺序:
方案一:
先更新数据库 再删除缓存
方案二:
先删除缓存 再更新数据库
两者都可能在并发条件下出现短暂不一致。实际选择需要考虑:
- 是否允许短暂旧数据;
- 数据更新频率;
- 是否有并发读写;
- 删除失败如何重试;
- 是否需要异步消息补偿;
- 数据库和 Redis 是否有统一事件机制。
对于库存、余额和权限等高风险数据,不应只依赖缓存作为判断依据。
5. 缓存穿透
缓存穿透是指请求查询一个缓存和数据库中都不存在的数据:
请求不存在的 ID -> Redis 未命中 -> 数据库也没有 -> 不写缓存 -> 下次请求重复打数据库
如果攻击者持续构造随机 ID,数据库可能被大量无效请求拖垮。
常见解决方式:
- 缓存空值并设置较短 TTL;
- 使用布隆过滤器初步判断;
- 做参数合法性校验;
- 对异常访问频率进行限流;
- 对不存在资源统一快速失败。
6. 缓存击穿
缓存击穿通常指一个热点 Key 在过期瞬间,大量请求同时发现缓存未命中:
热点 Key 过期 -> 大量请求同时查询数据库 -> 数据库瞬间承受高并发
常见解决方式:
- 互斥锁,只允许一个请求重建缓存;
- 逻辑过期,缓存物理上不立即删除;
- 热点数据提前刷新;
- 使用永不过期加主动更新;
- 对重建过程设置超时和降级。
7. 缓存雪崩
缓存雪崩通常指大量 Key 同时失效,或者 Redis 整体不可用:
大量 Key 同时失效 -> 请求集中访问数据库 Redis 故障 -> 所有缓存请求失败 -> 流量直接压向数据库
应对措施包括:
- TTL 增加随机值;
- 多级缓存;
- 热点 Key 预热;
- 过期重建限流;
- Redis 高可用;
- 数据库限流和降级;
- 缓存故障时返回兜底结果。
8. 分布式锁
单机 synchronized 只能锁住当前 JVM 内的线程,多个服务实例之间无法共享。Redis 分布式锁通常使用:
SET lock:order:100 unique-token NX EX 30
含义:
- NX:只有 Key 不存在时才设置;
- EX 30:设置过期时间,避免进程崩溃后永久持锁;
- unique-token:标识锁的持有者。
释放锁时必须确认 Value 仍然是自己的 Token,不能直接 DEL 一个可能已经被别人重新获得的锁。
9. 限流
限流可以按照不同维度实现:
- IP;
- 用户;
- API;
- 租户;
- 全局服务;
- 单个资源。
常见算法:
| 算法 | 特点 |
|---|---|
| 固定窗口 | 简单,但窗口边界可能突发 |
| 滑动窗口 | 更平滑,但记录更多 |
| 令牌桶 | 允许一定突发,适合接口限流 |
| 漏桶 | 输出平滑,适合控制处理速率 |
| 计数器 | 容易实现,但精度有限 |
Redis 的原子命令和 Lua 脚本适合实现多步骤限流判断。
10. Redis 事务、Lua 和 Pipeline
三者用途不同:
- Redis 事务:将多个命令排队执行,但不等同于数据库事务;
- Lua:将判断和修改放在 Redis 服务端原子执行;
- Pipeline:减少网络往返,但不自动提供业务原子性。
如果一个操作需要“读取—判断—修改”,而且中间不能被其他请求插入,通常应考虑 Lua 或原子命令。
三、工作原理
1. 缓存读写流程
写流程:
删除缓存失败时,数据库已经更新成功,但旧缓存可能继续存在。生产系统可以通过重试、消息队列、定时校验或短 TTL 进行补偿。
2. 缓存击穿的互斥重建
多个请求发现缓存失效 -> 尝试获取 rebuild-lock -> 只有一个请求获得锁 -> 获得锁的请求查询数据库并写缓存 -> 其他请求等待、重试或读取旧值
互斥锁本身也需要超时和异常处理:
- 锁必须设置过期时间;
- 业务执行时间不能明显超过锁 TTL;
- 释放锁要校验持有者;
- 获取锁失败要有等待上限;
- Redis 不可用时要有降级方案。
3. 分布式锁释放流程
安全释放锁的逻辑是:
读取 lock Key 的 Value -> 判断 Value 是否等于自己的 unique-token -> 相等才删除 -> 不相等则不能删除
读取和删除必须在 Redis 服务端原子完成,否则仍然存在竞态:
客户端 A 读取到自己的 Token 锁刚好过期 客户端 B 获取了新锁 客户端 A 执行 DEL 客户端 B 的锁被误删
因此释放锁应该使用 Lua 脚本。
4. Key 过期机制
Redis 的过期数据不会一定在过期瞬间被同步清理,而是会结合访问时检查和后台清理等机制处理。
应用层应该关注:
- TTL 是否被正确设置;
- 是否有大量永不过期 Key;
- 过期时间是否集中;
- 过期后的重建是否会打爆数据库;
- Key 删除后是否仍有并发重建;
- 内存淘汰策略是否符合业务要求。
不能把 TTL 当成精确的定时任务机制。需要精确时间触发的任务,应使用更合适的调度或消息方案。
5. Redis 内存淘汰
当 Redis 达到内存上限时,会根据配置淘汰部分 Key。缓存服务常见的策略是优先淘汰设置了过期时间且较少使用的数据,但具体策略应根据部署配置确认。
需要明确:
- Redis 是否只存缓存;
- 是否允许淘汰业务状态;
- 是否设置 maxmemory;
- 淘汰发生时应用如何降级;
- 哪些 Key 必须禁止淘汰;
- 是否需要拆分缓存实例和持久化数据实例。
如果同一个 Redis 实例同时存缓存、分布式锁和重要业务状态,淘汰策略可能互相冲突。
四、实战示例
下面使用 Spring Data Redis 展示常见的缓存、分布式锁和限流代码。示例强调核心思路,具体序列化和连接配置应根据项目环境调整。
1. 添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
项目还需要配置 Redis 连接地址、认证信息、数据库编号和连接池参数。敏感配置不应直接提交到代码仓库。
2. 配置 RedisTemplate
@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();
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}
生产项目必须明确序列化协议。JDK 默认序列化可能产生较大数据体、类名耦合和跨版本兼容问题。JSON 可读性更好,但要考虑类型信息、字段变更和安全反序列化。
3. 设计 Key 工具类
public final class RedisKeys {
private RedisKeys() {
}
public static String product(Long productId) {
return "cache:product:" + productId;
}
public static String productRebuildLock(Long productId) {
return "lock:product:rebuild:" + productId;
}
public static String rateLimit(
String userId,
String api) {
return "ratelimit:" + userId + ":" + api;
}
}
Key 设计建议:
- 使用统一前缀;
- 表达业务域和数据类型;
- 控制长度;
- 避免把完整用户输入直接拼接;
- 对租户、用户和环境做隔离;
- 为不同用途使用不同命名空间;
- 记录 Key 结构并建立监控规则。
4. 实现 Cache Aside 读取
@Service
public class ProductCacheService {
private static final Duration CACHE_TTL =
Duration.ofMinutes(10);
private final StringRedisTemplate redisTemplate;
private final ProductRepository productRepository;
private final ObjectMapper objectMapper;
public ProductCacheService(
StringRedisTemplate redisTemplate,
ProductRepository productRepository,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.productRepository = productRepository;
this.objectMapper = objectMapper;
}
public ProductView findById(Long productId) {
String key = RedisKeys.product(productId);
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if (cached.isEmpty()) {
return null;
}
return readProduct(cached);
}
ProductView product = productRepository.findViewById(productId);
if (product == null) {
redisTemplate.opsForValue().set(
key,
"",
Duration.ofSeconds(30)
);
return null;
}
redisTemplate.opsForValue().set(
key,
writeProduct(product),
withJitter(CACHE_TTL)
);
return product;
}
private Duration withJitter(Duration base) {
long jitter = ThreadLocalRandom.current()
.nextLong(0, 60);
return base.plusSeconds(jitter);
}
private ProductView readProduct(String value) {
try {
return objectMapper.readValue(
value,
ProductView.class
);
} catch (JsonProcessingException exception) {
throw new CacheSerializationException(
"缓存数据格式错误",
exception
);
}
}
private String writeProduct(ProductView product) {
try {
return objectMapper.writeValueAsString(product);
} catch (JsonProcessingException exception) {
throw new CacheSerializationException(
"缓存数据序列化失败",
exception
);
}
}
}
这里使用空字符串表示“数据库中不存在”。空值缓存 TTL 应该较短,避免新数据创建后长时间读不到。
生产代码还应考虑缓存读取失败时的策略:
Redis 读取失败 -> 是否允许直接查询数据库 -> 是否需要限流 -> 是否返回降级数据 -> 是否记录 Redis 故障指标
5. 更新数据库后删除缓存
@Transactional
public void updateProduct(
Long productId,
UpdateProductCommand command) {
productRepository.update(
productId,
command.name(),
command.price()
);
String key = RedisKeys.product(productId);
redisTemplate.delete(key);
}
数据库事务提交成功和删除缓存并不是一个原子操作。如果删除缓存失败,应该通过重试或消息补偿,而不是吞掉异常后认为一致性已经完成。
对于高一致性场景,可以在数据库事务中写入缓存失效事件:
更新业务数据 -> 写入本地消息表 -> 事务提交 -> 异步发送缓存失效事件 -> Redis 删除 -> 失败重试
6. 使用 Lua 安全释放锁
@Component
public class RedisLockService {
private static final DefaultRedisScript<Long>
UNLOCK_SCRIPT = new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] "
+ "then "
+ "return redis.call('del', KEYS[1]) "
+ "else "
+ "return 0 "
+ "end",
Long.class
);
private final StringRedisTemplate redisTemplate;
public RedisLockService(
StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public boolean tryLock(
String key,
String token,
Duration ttl) {
Boolean success = redisTemplate.opsForValue().setIfAbsent(
key,
token,
ttl
);
return Boolean.TRUE.equals(success);
}
public boolean unlock(
String key,
String token) {
Long result = redisTemplate.execute(
UNLOCK_SCRIPT,
List.of(key),
token
);
return Long.valueOf(1).equals(result);
}
}
调用锁时:
public ProductView findWithRebuildLock(Long productId) {
ProductView product = findFromCache(productId);
if (product != null) {
return product;
}
String key = RedisKeys.productRebuildLock(productId);
String token = UUID.randomUUID().toString();
if (lockService.tryLock(
key,
token,
Duration.ofSeconds(10))) {
try {
product = findFromCache(productId);
if (product != null) {
return product;
}
product = productRepository.findViewById(productId);
writeCache(productId, product);
return product;
} finally {
lockService.unlock(key, token);
}
}
return waitAndRetry(productId);
}
这个实现适合展示基本思路,但不等于完整的生产级锁方案。需要长时间持锁时,还要考虑自动续期、进程暂停、网络分区、时钟和锁服务故障。
7. 使用 Redis 实现固定窗口限流
@Component
public class RateLimiter {
private final StringRedisTemplate redisTemplate;
public RateLimiter(
StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public boolean allow(
String key,
int limit,
Duration window) {
Long count = redisTemplate.opsForValue()
.increment(key);
if (Long.valueOf(1).equals(count)) {
redisTemplate.expire(key, window);
}
return count != null && count <= limit;
}
}
这个版本简单易懂,但 increment 和 expire 之间存在窗口:如果应用在 increment 成功后崩溃,Key 可能没有 TTL。更稳妥的实现是用 Lua 将计数和过期设置放在一次脚本执行中。
8. Lua 限流脚本
private static final DefaultRedisScript<Long>
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>(
"local current = redis.call('incr', KEYS[1]) "
+ "if current == 1 then "
+ "redis.call('expire', KEYS[1], ARGV[1]) "
+ "end "
+ "if current <= tonumber(ARGV[2]) then "
+ "return 1 "
+ "else "
+ "return 0 "
+ "end",
Long.class
);
public boolean allow(
String key,
int limit,
Duration window) {
Long result = redisTemplate.execute(
RATE_LIMIT_SCRIPT,
List.of(key),
String.valueOf(window.toSeconds()),
String.valueOf(limit)
);
return Long.valueOf(1).equals(result);
}
限流 Key 应包含用户、租户、接口和时间窗口等必要维度。不要让客户端直接传入完整 Key,否则可能通过构造 Key 访问其他业务域的数据。
9. 使用 ZSet 实现排行榜
public void addScore(
Long userId,
double score) {
redisTemplate.opsForZSet().add(
"ranking:weekly",
userId.toString(),
score
);
}
public Set<ZSetOperations.TypedTuple<String>> top(
int limit) {
return redisTemplate.opsForZSet()
.reverseRangeWithScores(
"ranking:weekly",
0,
limit - 1
);
}
排行榜需要考虑:
- 分数是否需要持久化;
- 周期结束如何归档;
- 用户分数更新是否幂等;
- 成员数量是否会无限增长;
- 同分时如何稳定排序;
- Redis 故障时是否有数据库兜底。
10. 使用 Redis Stream 处理消息
如果使用 Redis Stream 作为消息流,需要关注:
- 消费者组;
- 消息确认;
- 未确认消息;
- 消费失败重试;
- 重复消费;
- 消息积压;
- Stream 长度控制;
- 消费者故障转移。
Redis 消息适合部分轻量场景,但不能因为它“能发消息”就替代所有消息队列。对可靠投递、长期堆积、复杂重试和跨系统事件,应根据业务要求选择专用消息系统或可靠事件方案。
五、常见问题与实践建议
1. Redis 中存 JSON 还是 Java 序列化对象
JDK 序列化的问题包括:
- 数据体积可能较大;
- 类名和包名耦合;
- 类结构变化可能无法反序列化;
- 跨语言使用困难;
- 调试不直观。
JSON 更容易跨语言和排查,但也需要处理:
- 字段新增和删除;
- 类型变化;
- 多态对象;
- 日期格式;
- 反序列化安全;
- 空值和默认值。
应把序列化格式当作数据契约管理,不能随意更换后直接复用旧缓存。
2. Key 设计为什么重要
糟糕的 Key 可能导致:
- 不同业务覆盖同一个 Key;
- 多租户数据互相污染;
- 无法按前缀统计;
- Key 过长浪费内存;
- 用户输入导致恶意构造;
- 集群中数据分布不均。
推荐结构:
环境:业务域:资源类型:租户:资源ID
例如:
prod:order:detail:tenant-a:10001
是否包含环境前缀取决于 Redis 实例是否隔离。生产和测试最好使用不同实例或明确数据库隔离,不能只依赖开发者记忆。
3. 为什么不能给所有缓存设置相同 TTL
如果大量 Key 在同一时间创建,并设置完全相同的 TTL,可能出现集中失效:
09:00 大量缓存写入,TTL 都是 10 分钟 09:10 大量缓存同时过期 09:10 大量请求回源数据库
可以通过随机抖动、预热和分批刷新缓解。但随机 TTL 只是降低同时失效概率,不能替代数据库限流和热点重建控制。
4. 缓存更新为什么会不一致
以下并发可能导致旧值回写:
线程 A:读取数据库旧值 线程 B:更新数据库并删除缓存 线程 A:把旧值写入缓存
这时缓存又变成旧数据。解决方式需要结合业务:
- 允许短暂不一致;
- 设置较短 TTL;
- 更新时增加版本号;
- 使用消息通知删除;
- 使用逻辑过期;
- 对关键数据直接读数据库;
- 通过串行化或分布式锁控制重建。
没有一种方案能在所有业务中同时做到零延迟、强一致、低成本和高并发。
5. 分布式锁为什么会误删别人的锁
错误写法:
redisTemplate.delete(lockKey);
如果当前线程执行时间超过 TTL,锁可能已经被另一个线程重新获取。此时直接删除就会误删别人的锁。
正确方式是:
- 设置唯一 Token;
- 释放时比较 Token;
- 比较和删除使用 Lua 原子执行;
- 业务执行时间超过 TTL 时考虑续期;
- 获取锁和释放锁都设置超时;
- 不要将锁当作绝对正确性保障。
6. 分布式锁能保证业务绝对正确吗
不能。Redis 锁可能受到:
- Redis 故障;
- 网络分区;
- 客户端暂停;
- 锁过期;
- 主从切换;
- 误配置;
- 业务代码绕过锁。
对库存、余额等关键业务,应同时使用数据库条件更新、版本号、唯一约束和幂等设计。锁是并发协调手段,不应成为唯一数据正确性保障。
7. Redis 故障时应用怎么办
应用需要明确降级策略:
缓存故障 -> 是否允许直读数据库 -> 是否对回源限流 -> 是否返回静态兜底 -> 是否暂时关闭非核心功能 -> 是否告警并自动恢复
不同数据的策略可能不同:
- 商品展示:可以返回旧缓存或默认信息;
- 用户权限:不能随意使用旧权限;
- 库存:应查询权威数据库或直接失败;
- 推荐列表:可以降级为空;
- 限流器:可以切换本地限流。
8. Redis 适合保存敏感信息吗
Redis 中的 Token、手机号、验证码和用户状态都可能被运维、日志、备份或误授权访问。应考虑:
- 网络访问控制;
- 身份认证;
- TLS;
- 最小权限;
- 敏感值加密或脱敏;
- 合理 TTL;
- 禁止在日志中打印完整 Value;
- 备份和持久化文件保护。
验证码等短期数据应设置短 TTL,并限制尝试次数,避免被反复猜测。
9. Pipeline 会不会自动保证原子性
不会。Pipeline 主要减少客户端与 Redis 之间的网络往返,将多条命令批量发送。它不等于事务,也不保证多条命令之间不可插入。
如果需要原子性:
- 使用单条原子命令;
- 使用 Lua;
- 根据场景使用 Redis 事务;
- 设计可重试和幂等操作。
10. Redis 连接池怎么设置
连接池不是越大越好。连接过多可能造成:
- Redis 端连接压力;
- 应用线程争抢;
- 超时和排队增加;
- 数据库或下游服务成为瓶颈;
- 资源浪费。
应结合:
- 应用实例数量;
- Redis 最大连接数;
- 请求并发;
- 单次命令耗时;
- Pipeline 和批量操作;
- 超时和重试策略。
11. 重试会不会放大故障
Redis 命令失败后无条件快速重试,可能形成重试风暴:
Redis 响应变慢 -> 应用超时重试 -> Redis 收到更多请求 -> 响应更慢 -> 更多请求重试
重试应该:
- 限制次数;
- 使用退避;
- 增加随机抖动;
- 区分连接错误和业务错误;
- 配合熔断;
- 为非幂等操作谨慎重试;
- 记录降级和重试指标。
六、进阶思考
1. 缓存和数据库的一致性分级
可以按业务接受程度分级:
| 级别 | 方案 | 适用场景 |
|---|---|---|
| 弱一致 | TTL 自然过期 | 内容展示、推荐 |
| 最终一致 | 更新后删缓存加重试 | 普通详情和配置 |
| 较强一致 | 版本校验或消息失效 | 关键业务读取 |
| 强一致 | 直接读数据库并使用事务 | 余额、库存、权限 |
一致性越强,通常成本和复杂度越高。不要为了缓存命中率牺牲关键业务正确性。
2. 多级缓存
多级缓存可能包含:
本地缓存
-> Redis
-> 数据库
-> 下游服务
优点:
- 减少 Redis 网络开销;
- 降低热点 Key 压力;
- Redis 故障时有部分本地兜底。
风险:
- 本地缓存和 Redis 都可能过期;
- 多实例之间失效通知复杂;
- 内存使用增加;
- 数据不一致窗口扩大;
- 更新和发布流程更复杂。
多级缓存需要明确失效通知、版本号和容量上限,不能简单叠加。
3. Redis Cluster 下的 Key 分布
集群会根据 Key 计算槽位。如果一个业务操作需要多个 Key 位于同一个槽位,可以使用 Hash Tag:
order:{10001}:detail
order:{10001}:items
大括号中的内容用于计算槽位,使相关 Key 更可能落在同一个槽位。但 Hash Tag 过度使用会造成热点槽位和数据倾斜。
集群设计需要同时考虑:
- Key 分布;
- 单个业务资源的热点;
- 多 Key 操作;
- 扩缩容;
- 故障转移;
- 大 Key;
- 连接拓扑感知。
4. Redis 持久化和缓存定位
如果 Redis 只是缓存,丢失后可以从数据库重建,持久化要求可能相对低;如果 Redis 保存重要状态或消息,持久化和恢复要求就更高。
需要明确:
Redis 中的数据是否允许丢失 Redis 重启后是否能恢复 恢复需要多长时间 主从切换会不会丢数据 消息是否需要重新消费 数据库是否仍然保存权威数据
不要把持久化开关当作业务备份方案。重要业务数据仍应有独立备份和恢复演练。
5. 监控哪些 Redis 指标
基础监控包括:
- 内存使用量和内存碎片;
- Key 数量和过期 Key 数量;
- 命令吞吐;
- 命令耗时分布;
- 慢命令;
- 命中率和回源率;
- 连接数;
- 阻塞客户端;
- 主从复制延迟;
- Stream 消费积压;
- Key 淘汰数量;
- 错误和超时数量。
应用侧还要记录:
- 缓存命中率;
- 缓存穿透数量;
- 缓存重建耗时;
- 获取锁成功率;
- 锁等待和锁超时;
- 限流拒绝数;
- Redis 降级次数;
- 数据库回源峰值。
6. 缓存预热和热点保护
应用发布、Redis 重启或大促开始前,可以预热高频数据:
识别热点 Key -> 分批加载 -> 控制并发 -> 设置随机 TTL -> 校验缓存内容 -> 开放流量
热点保护还可以使用:
- 本地缓存;
- 请求合并;
- 单飞机制;
- 逻辑过期;
- 预刷新;
- 分片 Key;
- 接口限流。
不要在应用启动时无上限地加载整个数据库。
7. 选择 Redisson 还是自己实现
自己实现 Redis 锁和限流可以帮助理解原理,但生产系统还要处理续期、重试、故障和监控。Redisson 等成熟客户端提供了更多分布式对象和锁能力。
选择时应考虑:
- 是否需要可重入锁;
- 是否需要自动续期;
- 是否使用集群;
- 是否需要读写锁、公平锁或信号量;
- 团队是否能维护自定义实现;
- 故障行为是否符合业务要求。
任何客户端都不能消除分布式系统的网络和故障风险,仍然需要业务幂等和数据库兜底。
8. Redis 使用检查清单
上线前可以检查:
是否明确 Redis 中数据的权威来源 是否为缓存设置 TTL 是否处理空值和热点 Key 是否控制 Key 长度和大对象 是否避免生产使用 KEYS 是否设置连接和命令超时 是否配置合理重试和熔断 是否验证序列化兼容 是否隔离租户和环境 是否保护敏感信息 是否监控命中率、回源和内存 是否演练 Redis 故障和恢复
结论
Redis 可以显著降低数据库压力并提升接口响应速度,但它同时引入了缓存一致性、过期、故障、内存和分布式协调等问题。正确使用 Redis 的关键,不是记住更多命令,而是明确数据来源、业务一致性、故障降级和运维边界。
本文的重点可以归纳为:
- Redis 适合缓存、计数、限流、锁、排行榜和临时状态等场景;
- Cache Aside 是最常见的缓存模式;
- TTL、随机过期和热点重建控制是缓存稳定性的基础;
- 穿透、击穿和雪崩需要分别处理;
- 分布式锁必须使用唯一 Token、过期时间和原子释放;
- 关键业务不能只依赖 Redis 锁,还需要数据库条件更新、版本号和幂等;
- Key、序列化、大 Key、Pipeline 和重试策略都需要工程化设计;
- Redis 故障时必须有明确的回源、限流、降级和告警策略;
- 多租户、敏感数据、集群槽位和持久化需要在上线前规划;
- 监控命中率、回源率、延迟、内存、锁等待和积压,才能真正发现风险。
以上就是Redis在Java项目中的常见应用与踩坑实践的详细内容,更多关于Java中最常见的Redis使用场景的资料请关注其它相关文章!










