首页 > 数据库    日期:2026-09-12 / 浏览:次

摘要

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. 缓存读写流程

数据库RedisJava 服务客户端数据库RedisJava 服务客户端alt[缓存命中][缓存未命中]查询商品GET cache:product:10返回缓存数据返回商品nilSELECT 商品返回商品SET 商品和 TTL返回商品

写流程:

Redis数据库Java 服务Redis数据库Java 服务UPDATE 商品更新成功DEL cache:product:10删除完成

删除缓存失败时,数据库已经更新成功,但旧缓存可能继续存在。生产系统可以通过重试、消息队列、定时校验或短 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使用场景的资料请关注其它相关文章!

觉得上面的内容有用吗?快来点个赞吧!

点赞() 我要打赏

温馨提示 : 本站内容来自会员投稿以及互联网,所有源码及教程均为作者总结编辑,请大家在使用过程中提前做好备份,以免发生无法预知的错误,源码类教程请勿直接用于生产环境!

 可能感兴趣的文章