摘要
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使用场景的资料请关注代码网其它相关文章!
发表评论