当前位置: 代码网 > it编程>编程语言>Java > Redis在Java项目中的常见应用与踩坑实践

Redis在Java项目中的常见应用与踩坑实践

2026年09月10日 Java 我要评论
摘要redis 是 java 后端项目中非常常见的基础组件。它可以用作缓存、分布式锁、限流器、会话存储、排行榜、消息队列和临时状态存储,但不同场景对数据一致性、持久化、过期策略和故障恢复的要求并不相同

摘要

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、延迟任务消费、重试和堆积
分布式状态验证码、幂等 tokenttl、隔离和删除

一个 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使用场景的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com