空值缓存(附Java代码)

4 阅读7分钟

前言

在高并发的互联网应用场景中,缓存(如 Redis)已经成为保护数据库、提升系统响应速度的标准配置。然而,很多初中级开发者在使用缓存时,往往只关注缓存命中(Cache Hit)的情况,而忽略了查询结果为空(Null)的场景。

当大量请求查询一个不存在的数据时,如果每次请求都直接穿透缓存到达数据库,不仅会造成数据库压力激增,还可能导致系统响应变慢。这种现象被称为“缓存穿透”。

本文将通过通俗易懂的语言和完整的代码示例,带你掌握“空值缓存”技术,学会如何优雅地解决缓存穿透问题。

核心概念:什么是空值缓存?

1. 缓存穿透问题

想象一下,你是一家图书馆的管理员。读者经常来查询某本书是否存在。

  • 正常情况:书存在,你告诉读者书架号,读者直接去拿。
  • 异常情况:书不存在。如果你每次都说“我去库房查一下”,然后回来告诉读者“没有”,那么如果有 1 万个读者同时查询这本不存在的书,你就需要往返库房 1 万次。库房(数据库)会被累垮。

在系统中,这就是缓存穿透:查询一个根本不存在的数据,缓存层不存储,请求直接落到数据库层。如果恶意攻击者利用这一点,频繁查询不存在的 ID,可能导致数据库宕机。

2. 空值缓存解决方案

空值缓存的核心思想是:即使数据库查询结果为空,也将这个“空结果”存入缓存中,并设置一个较短的过期时间。

回到图书馆的例子:

  • 当发现书不存在时,你在查询台上贴一个纸条:“这本书没有,有效期 5 分钟”。
  • 接下来的 5 分钟内,再有读者来问,你直接看纸条告诉他“没有”,不需要再去库房。
  • 5 分钟后,纸条失效。如果书真的上架了,下次查询就能正常获取。

3. 方案对比

方案数据库压力缓存内存占用数据一致性实现复杂度
不使用空值缓存高(每次查询都访问 DB)
使用空值缓存低(仅第一次访问 DB)中(存储空键值)中(依赖过期时间)

代码实战:从入门到优化

为了让大家能够直接运行理解,我们将使用本地 Map 模拟 Redis 缓存,使用随机逻辑模拟数据库查询。

1. 场景设定

  • 业务:根据用户 ID 查询用户信息。
  • 问题:某些 ID 在数据库中不存在。
  • 目标:防止不存在的 ID 频繁查询数据库。

2. 完整代码示例

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * 用户实体类
 */
class User {
    private Long id;
    private String name;

    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public String toString() {
        return "User{id=" + id + ", name='" + name + "'}";
    }
}

/**
 * 模拟缓存条目,包含值和过期时间
 */
class CacheEntry {
    private Object value;
    private long expireTime; // 过期时间戳

    public CacheEntry(Object value, long ttlMillis) {
        this.value = value;
        // 如果 ttl 为 0,表示不过期(实际生产中不建议空值不过期)
        if (ttlMillis > 0) {
            this.expireTime = System.currentTimeMillis() + ttlMillis;
        } else {
            this.expireTime = Long.MAX_VALUE;
        }
    }

    public boolean isExpired() {
        return System.currentTimeMillis() > expireTime;
    }

    public Object getValue() {
        return value;
    }
}

/**
 * 模拟缓存服务(实际项目中通常使用 Redis)
 */
class CacheService {
    private final Map<String, CacheEntry> cache = new HashMap<>();

    // 存入缓存
    public void put(String key, Object value, long ttlMillis) {
        cache.put(key, new CacheEntry(value, ttlMillis));
        System.out.println("[缓存] 写入 key: " + key + ", 值: " + (value == null ? "NULL" : value));
    }

    // 获取缓存
    public Object get(String key) {
        CacheEntry entry = cache.get(key);
        if (entry == null) {
            return null;
        }
        // 检查是否过期
        if (entry.isExpired()) {
            cache.remove(key);
            System.out.println("[缓存] key: " + key + " 已过期,移除");
            return null;
        }
        return entry.getValue();
    }
}

/**
 * 模拟数据库服务
 */
class DatabaseService {
    // 模拟数据库中存在的数据 ID 为 1, 2, 3
    public User queryById(Long id) {
        System.out.println("[数据库] 查询 ID: " + id);
        // 模拟数据库查询耗时
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        if (id >= 1 && id <= 3) {
            return new User(id, "User" + id);
        }
        // 返回 null 表示数据不存在
        return null;
    }
}

/**
 * 业务服务层:包含空值缓存逻辑
 */
class UserService {
    private final CacheService cacheService = new CacheService();
    private final DatabaseService databaseService = new DatabaseService();

    // 缓存 key 前缀
    private static final String CACHE_KEY_PREFIX = "user:";
    // 正常数据缓存时间:5 分钟
    private static final long NORMAL_CACHE_TTL = 5 * 60 * 1000;
    // 空值缓存时间:30 秒(防止数据不一致时间过长)
    private static final long NULL_CACHE_TTL = 30 * 1000;

    public User getUserById(Long id) {
        String key = CACHE_KEY_PREFIX + id;

        // 1. 先查缓存
        Object cachedValue = cacheService.get(key);

        // 2. 缓存命中
        if (cachedValue != null) {
            // 特殊标记:如果缓存中存的是特定对象表示空值,这里简化为直接判断 null
            // 在实际 Redis 中,通常存储一个特殊字符串如 "NULL_VALUE"
            if (cachedValue instanceof NullObject) {
                System.out.println("[缓存] 命中空值标记,直接返回 null");
                return null;
            }
            System.out.println("[缓存] 命中正常数据");
            return (User) cachedValue;
        }

        // 3. 缓存未命中,查询数据库
        User user = databaseService.queryById(id);

        // 4. 写入缓存
        if (user == null) {
            // 关键点:数据库也为空,存入空值缓存,防止穿透
            // 使用一个特殊对象标记空值,因为 Map 的 null 可能代表缓存未命中
            cacheService.put(key, new NullObject(), NULL_CACHE_TTL);
        } else {
            // 数据库有数据,存入正常缓存
            cacheService.put(key, user, NORMAL_CACHE_TTL);
        }

        return user;
    }
}

/**
 * 空值标记类,用于区分“缓存没数据”和“数据库查出来是空”
 */
class NullObject {
    @Override
    public String toString() {
        return "NULL_OBJECT_MARKER";
    }
}

/**
 * 测试主程序
 */
public class NullCacheDemo {
    public static void main(String[] args) {
        UserService userService = new UserService();

        System.out.println("=== 第一次查询 ID: 100 (数据不存在) ===");
        User user1 = userService.getUserById(100L);
        System.out.println("结果:" + user1);

        System.out.println("\n=== 第二次查询 ID: 100 (命中空值缓存) ===");
        User user2 = userService.getUserById(100L);
        System.out.println("结果:" + user2);

        System.out.println("\n=== 第三次查询 ID: 1 (数据存在) ===");
        User user3 = userService.getUserById(1L);
        System.out.println("结果:" + user3);

        System.out.println("\n=== 第四次查询 ID: 1 (命中正常缓存) ===");
        User user4 = userService.getUserById(1L);
        System.out.println("结果:" + user4);
    }
}

3. 代码逻辑解析

  1. NullObject 标记类:在 Java Map 中,null 值通常表示键不存在。为了区分“缓存里没有这个键”和“数据库查出来是空”,我们定义了一个 NullObject 类。在实际 Redis 使用中,通常存储一个特殊字符串(如 "NULL")。
  2. 差异化过期时间
    • 正常数据:缓存时间较长(如 5 分钟),减少数据库压力。
    • 空值数据:缓存时间较短(如 30 秒),兼顾性能与数据一致性。如果数据库中稍后插入了该数据,缓存过期后能很快同步。
  3. 流程闭环:查询缓存 -> 命中返回 -> 未命中查库 -> 回写缓存(无论是否为空)。

常见问题与踩坑点

在实际生产环境中实施空值缓存,需要注意以下几个关键问题:

问题点风险描述建议解决方案
空值过期时间设置过长如果数据库中新插入了该数据,但缓存中的空值未过期,用户将无法查到新数据。空值缓存 TTL 宜短不宜长,建议设置为秒级(如 30s - 5min)。
空值过期时间设置过短起不到保护数据库的作用,恶意请求仍可频繁穿透。根据业务容忍度权衡,通常不低于 10 秒。
内存浪费如果有大量随机 ID 攻击,缓存中会存储大量空值键,占用内存。配合布隆过滤器(Bloom Filter)使用,先在拦截层过滤不存在的 ID。
数据一致性缓存中的空值可能导致短暂的数据不可见。接受最终一致性,或在数据写入时主动删除对应的空值缓存。
缓存雪崩风险如果大量空值缓存同一时间过期,压力会瞬间回到数据库。空值缓存的过期时间应加入随机值(如 30 秒 + 随机 1-5 秒)。

总结

空值缓存是解决缓存穿透问题最简单、最有效的方案之一。作为 Java 开发者,在设计缓存策略时,不应只关注正常流程,更要考虑异常和边界情况。

核心要点回顾:

  1. 识别场景:当存在大量查询不存在数据的请求时,必须考虑空值缓存。
  2. 标记空值:使用特殊对象或字符串区分“缓存miss"和“数据 null"。
  3. 控制 TTL:空值缓存的过期时间要短,并增加随机性,防止一致性问题和雪崩。
  4. 组合方案:对于高安全要求的场景,建议结合布隆过滤器进行多重防御。