Redis

数据库像一位严谨的图书管理员——每次查询都要翻目录、找书架、取书、登记,慢条斯理但绝对可靠。而 Redis(Remote Dictionary Server)更像你桌边那个永远敞开的抽屉——随手一抓就是你要的东西,快得让人怀疑人生。

它快到什么程度?单机 QPS 轻松突破 10 万,所有数据都躺在内存里,读写以微秒计。2009 年意大利程序员 Salvatore Sanfilippo(网名 antirez)为了自己的项目,造了这么一个”内存数据库”。十几年过去,Redis 成了后端工程师的标配武器——缓存、队列、计数器、排行榜、分布式锁,处处是它的身影。

这一章我们看 Redis 的五大数据类型、两种持久化、缓存三大问题、分布式锁,以及 Java 怎么和 Redis 对话。

一、为什么需要 Redis

数据库(MySQL/PostgreSQL)把数据存磁盘上——可靠,但慢。一次磁盘 I/O 要几毫秒,而内存访问只要几十纳秒,相差五位数。当你的系统读多写少(比如商品详情页、热门文章),每次都查数据库就是浪费。

缓存(Cache) 的思路很简单——把热点数据放内存,读时先查内存,命中就直接返回,没命中再查数据库并写回内存。Redis 就是这个”内存层”的事实标准。

Redis 不只是缓存——它的数据类型丰富,能干很多事:

场景用什么
缓存热点数据String
排行榜ZSet(有序集合)
点赞/关注Set
购物车Hash
消息队列(轻量)List
限流ZSet / List
分布式锁String + SETNX
地理位置Geo
统计 UVHyperLogLog

二、五大数据类型

Redis 的”数据类型”不是指 int/float 这种——而是指 value 的存储结构。所有 key 都是字符串,value 有五种基本类型。

2.1 String:最基础的键值对

SET name "redis"        # 设置
GET name                # 读取, 返回 "redis"
INCR counter            # 自增 (原子操作)
EXPIRE name 60          # 60 秒后过期
SET token "abc" EX 60   # 设置并指定过期 (EX 秒)

String 能存字符串、数字、甚至二进制(图片)。最大 512MB。

2.2 List:有序列表

LPUSH queue "a" "b"     # 左侧插入
RPUSH queue "c"         # 右侧插入
LRANGE queue 0 -1       # 查看全部
LPOP queue              # 左侧弹出

List 是双向链表,头尾操作都是 O(1)。常用来做队列。

2.3 Hash:字段-值表

HSET user:1 name "Tom" age 18    # 设置字段
HGET user:1 name                 # 读单个字段
HGETALL user:1                   # 读所有字段
HINCRBY user:1 age 1             # 字段自增

Hash 适合存对象——比把对象序列化成 String 更灵活,改单个字段不用整体重写。

2.4 Set:无序集合

SADD tags "java" "redis"         # 添加元素
SMEMBERS tags                    # 查看全部
SISMEMBER tags "java"            # 判断是否成员
SINTER set1 set2                 # 交集
SUNION set1 set2                 # 并集

Set 元素唯一,常用来做标签、去重、共同好友。

2.5 ZSet:有序集合

ZADD rank 100 "alice" 90 "bob"   # 添加带分数的成员
ZRANGE rank 0 2                  # 按分数升序取前 3
ZREVRANGE rank 0 2               # 降序取前 3 (排行榜)
ZINCRBY rank 5 "alice"           # 增加分数

ZSet(Sorted Set)是 Redis 的”杀手锏”——每个元素带一个分数,按分数排序。排行榜就靠它。

三、持久化:内存数据的安全网

Redis 数据在内存里——重启就没了。为了不丢数据,Redis 提供两种持久化方案。

3.1 RDB(Redis Database)

RDB 是某一时刻的内存快照——把所有数据写入一个 .rdb 文件。

  • 触发方式——手动 SAVE/BGSAVE,或配置 save 900 1(900 秒内 1 次修改就触发)。
  • 优点——文件小,恢复快,适合做备份。
  • 缺点——两次快照之间的数据可能丢失。

3.2 AOF(Append Only File)

AOF 把每条写命令追加到日志文件——像数据库的 binlog。

  • 触发策略——appendfsync always(每次写都同步,最安全最慢)/ everysec(每秒同步,默认,最多丢 1 秒)/ no(交给操作系统)。
  • 优点——数据丢失少(最差 1 秒)。
  • 缺点——文件大,恢复慢。

3.3 混合持久化(Redis 4.0+)

实际生产环境常用混合模式——AOF 文件前半段是 RDB 二进制快照,后半段是增量的 AOF 命令。兼顾恢复速度和数据安全。

四、缓存三大问题

缓存用不好会出事。三个经典坑——穿透、击穿、雪崩。

4.1 缓存穿透

穿透——查询一个根本不存在的数据,缓存里没有,数据库里也没有。每次请求都打到数据库。恶意攻击常用这招——用不存在的 ID 疯狂查询。

解法

  1. 缓存空值——数据库查不到,也缓存一个 null(带短过期)。
  2. 布隆过滤器(Bloom Filter)——在缓存前加一层布隆过滤器,快速判断 key 是否”可能存在”。不存在直接返回。

4.2 缓存击穿

击穿——某个热点 key 突然过期,瞬间大量请求同时打到数据库。比如秒杀商品的库存 key 过期了。

解法

  1. 互斥锁——只让一个线程查数据库,其他线程等结果。
  2. 热点 key 永不过期——后台异步更新缓存。

4.3 缓存雪崩

雪崩——大量 key 同时过期,或 Redis 宕机,所有请求涌向数据库。

解法

  1. 过期时间加随机数——expire = base + random(60),避免同时过期。
  2. 多级缓存——本地缓存(Caffeine)+ Redis,Redis 挂了本地还能顶一会。
  3. Redis 集群 + 限流降级——保证 Redis 高可用,数据库加限流保护。

五、分布式锁

单机环境下,Java 有 synchronizedReentrantLock。但分布式系统有多个 JVM,这两个都失效了——需要分布式锁

5.1 SETNX 的简单实现

最朴素的分布式锁——用 SET key value NX EX timeout

  • NX——key 不存在才设置(互斥)。
  • EX timeout——设置过期时间,防止持锁进程崩溃导致锁永不释放。
String lockValue = UUID.randomUUID().toString();
// 抢锁: NX (不存在才设) + EX (30 秒过期)
boolean locked = redis.set("lock:order:123", lockValue, "NX", "EX", 30);

if (locked) {
    try {
        // 业务逻辑
    } finally {
        // 释放锁: 必须验证值, 不能误删别人的锁
        String script =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "  return redis.call('del', KEYS[1]) " +
            "else return 0 end";
        redis.eval(script, Collections.singletonList("lock:order:123"),
                   Collections.singletonList(lockValue));
    }
}

为什么释放锁要用 Lua 脚本?因为”判断值 + 删除”必须是原子的——否则判断完准备删时锁过期了,别人抢到了锁,你这一删就把别人的锁删了。

5.2 Redisson:生产级方案

SETNX 方案有个问题——业务执行超过锁过期时间,锁被别人抢了,自己却以为还持锁。Redisson看门狗(Watchdog) 解决——后台线程定期给锁续期。

// Redisson 客户端
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
RedissonClient redisson = Redisson.create(config);

RLock lock = redisson.getLock("lock:order:123");
lock.lock();   // 加锁, 看门狗自动续期
try {
    // 业务逻辑
} finally {
    lock.unlock();
}

Redisson 还支持可重入公平锁读写锁红锁(RedLock,多节点容错)——生产环境首选。

六、Java 客户端:Lettuce 与 Jedis

Java 连 Redis 有两大客户端——JedisLettuce

6.1 Jedis

Jedis 是老牌客户端,API 简单直观,但非线程安全——多线程要靠连接池(JedisPool)。

JedisPool pool = new JedisPool("localhost", 6379);
try (Jedis jedis = pool.getResource()) {
    jedis.set("name", "redis");
    String value = jedis.get("name");
    jedis.incr("counter");
}

6.2 Lettuce

Lettuce 是新一代客户端——线程安全,基于 Netty 异步,支持响应式。Spring Boot 2.x 起默认用 Lettuce。

RedisClient client = RedisClient.create("redis://localhost:6379");
StatefulRedisConnection<String, String> conn = client.connect();
RedisCommands<String, String> sync = conn.sync();

sync.set("name", "redis");
String value = sync.get("name");

// 异步 API
RedisAsyncCommands<String, String> async = conn.async();
async.set("counter", "1");

conn.close();
client.shutdown();

6.3 Spring Boot 整合

Spring Boot 用 spring-boot-starter-data-redis,封装了 RedisTemplate

@Service
public class UserService {
    @Autowired
    private RedisTemplate<String, Object> redis;

    public User getUser(Long id) {
        String key = "user:" + id;
        User user = (User) redis.opsForValue().get(key);
        if (user != null) return user;   // 缓存命中

        user = userMapper.selectById(id); // 查数据库
        redis.opsForValue().set(key, user, 60, TimeUnit.MINUTES);
        return user;
    }
}

七、实战:用 Java SE 模拟 Redis

Piston 在线环境跑不了 Redis 服务器,我们用 Java SE 模拟 Redis 的核心数据结构——String/List/Hash/Set/ZSet 五大类型,演示各自的操作。同时模拟缓存穿透、缓存击穿、互斥锁的解决思路。

Java · 在线运行

观察重点:String 的 INCR 是原子自增,并发安全;SETNX 是分布式锁的基石——返回 true 表示抢锁成功;缓存空值能挡住对不存在 key 的反复查询;互斥锁重建避免缓存击穿时数据库被压垮。

八、缓存一致性:数据库与缓存谁先更新

缓存引入一个新问题——数据一致性。数据库更新了,缓存怎么办?

8.1 Cache Aside(旁路缓存)

最常用的模式——读时查缓存,没命中查数据库并写回;写时更新数据库 + 删除缓存

public User getUser(Long id) {
    User user = redis.get("user:" + id);
    if (user == null) {
        user = db.selectById(id);
        redis.set("user:" + id, user);   // 回填缓存
    }
    return user;
}

public void updateUser(User user) {
    db.update(user);
    redis.del("user:" + user.getId());   // 删除缓存 (不是更新)
}

为什么是删除缓存而不是更新缓存?

  1. 避免并发更新导致数据错乱。
  2. 有些缓存值计算复杂,更新一次成本高,不如用时再算。

8.2 延迟双删

更新数据库后删缓存,但删完缓存到数据库提交之间可能有旧读请求又把旧数据写回缓存。延迟双删——先删缓存,更新数据库,延迟一段时间再删一次缓存。

redis.del(key);          // 第一次删
db.update(user);         // 更新数据库
scheduleDeleteAfter(key, 500);   // 500ms 后再删一次

九、Redis 的局限与替代品

Redis 不是银弹——它也有局限:

  • 内存成本高——比磁盘贵几十倍,不适合存全量数据。
  • 重启数据可能丢失——AOF 也最多保证不丢 1 秒。
  • 单线程模型——CPU 密集计算会阻塞(Redis 6 起网络 I/O 多线程,命令执行仍单线程)。

某些场景的替代品:

  • 大容量缓存——Memcached(更简单,多线程)。
  • 需要事务/复杂查询——用关系数据库的内存表。
  • 本地缓存——Caffeine / Guava Cache(无网络开销,但数据不共享)。

十、本章小结

知识点要点
五大数据类型String / List / Hash / Set / ZSet
持久化RDB 快照、AOF 日志、混合持久化
缓存穿透查不存在的数据 → 缓存空值 / 布隆过滤器
缓存击穿热点 key 过期 → 互斥锁 / 永不过期
缓存雪崩大量 key 同时过期 → 随机过期 / 多级缓存
分布式锁SETNX + 过期 + Lua 释放;Redisson 看门狗
Java 客户端Jedis(同步,需连接池)/ Lettuce(异步,Spring 默认)
缓存一致性Cache Aside:写时删除缓存;延迟双删

记忆口诀

  • 五类型——串(String)列(List)哈(Hash)集(Set)序(ZSet)。
  • 三问题——穿(穿透,查不存在)击(击穿,热点过期)雪(雪崩,集体过期)。
  • 三解法——空值挡穿透,互斥防击穿,随机避雪崩。
  • 分布式锁三件套——SETNX 抢、EXPIRE 防、Lua 释放。
  • 客户端二选一——Jedis 简单要池化,Lettuce 异步是默认。

结语

Redis 把”快”做到了极致——内存存储、单线程模型、丰富数据结构,让它成了后端系统的瑞士军刀。但快不是没代价——内存有限、数据可能丢、缓存一致性要小心。

下一章我们看消息队列——当系统需要异步解耦、削峰填谷时,RabbitMQ 和 Kafka 登场。如果说 Redis 是”快”的代名词,那消息队列就是”稳”的守护者——它让生产者和消费者各跑各的,互不阻塞。