Skip to content

源码:labs/java/backend-data/cache-aside-simulation/src/CacheAsideSimulation.java ​

  • 原始路径:labs/java/backend-data/cache-aside-simulation/src/CacheAsideSimulation.java
  • 类型:java
java
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Cache-Aside 缓存仿真:穿透 / 击穿 / 雪崩 / 双写一致性。
 *
 * 对应 docs 06-backend-data/07-cache-strategy-and-pitfalls.md。
 * 纯 Java CLI(无外部依赖),模拟 Redis(简化 Map) + MySQL(简化 Map):
 *
 * 1. Cache-Aside 读路径:读 Cache → Miss → 读 DB → 写回 Cache
 * 2. 缓存穿透:查不存在的 key,绕过缓存直击 DB(布隆过滤器可拦截)
 * 3. 缓存击穿:热点 key 过期瞬间并发重建(互斥锁只让一个线程回源)
 * 4. 缓存雪崩:批量 key 同时过期(随机 TTL 缓解)
 * 5. 双写一致性:先删 Cache 后更 DB 的窗口期不一致 vs 先更 DB 后删 Cache
 *
 * 运行:javac -d out src/CacheAsideSimulation.java && java -cp out CacheAsideSimulation
 */
public class CacheAsideSimulation {

    /** 简化 Redis:key -> value(value 可能为 null 表示已删除) */
    static final Map<String, String> CACHE = new ConcurrentHashMap<>();
    /** 简化 MySQL:key -> value */
    static final Map<String, String> DB = new HashMap<>();
    static final Random RANDOM = new Random();
    static int dbHits = 0; // 统计穿透到 DB 的次数(衡量防护效果)

    public static void main(String[] args) throws Exception {
        // 造数据
        DB.put("user:1", "Alice");
        DB.put("user:2", "Bob");
        CACHE.put("user:1", "Alice");

        System.out.println("========== 1. Cache-Aside 读路径 ==========");
        System.out.println("  get(user:1) -> " + cacheAsideGet("user:1") + "(命中缓存)");
        System.out.println("  get(user:2) -> " + cacheAsideGet("user:2") + "(Miss 后回源 DB 并写回缓存)");

        System.out.println();
        System.out.println("========== 2. 缓存穿透:不存在的 key 每次打 DB ==========");
        dbHits = 0;
        for (int i = 0; i < 1000; i++) cacheAsideGet("user:not_exist");
        System.out.println("  1000 次查询不存在 key,穿透到 DB 次数 = " + dbHits + "(无防护,全量打 DB)");
        System.out.println("  修法:布隆过滤器拦截 or 缓存空值(TTL 短)");

        System.out.println();
        System.out.println("========== 3. 缓存击穿:热点 key 过期瞬间并发重建 ==========");
        CACHE.remove("user:1"); // 模拟热点 key 过期
        dbHits = 0;
        // 50 个线程同时读热点 key
        Thread[] threads = new Thread[50];
        for (int i = 0; i < 50; i++) {
            threads[i] = new Thread(() -> getWithMutex("user:1"));
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        System.out.println("  50 并发读热点 key(互斥锁防护),实际回源 DB 次数 = " + dbHits + "(应为 1)");

        System.out.println();
        System.out.println("========== 4. 缓存雪崩:批量 key 同时过期 vs 随机 TTL ==========");
        demoAvalanche(false); // 固定 TTL
        demoAvalanche(true);  // 随机 TTL

        System.out.println();
        System.out.println("========== 5. 双写一致性:先删缓存 vs 先更 DB ==========");
        demoDoubleWrite();

        System.out.println();
        System.out.println("结论:Cache-Aside + 互斥锁防击穿 + 随机 TTL 防雪崩 + 先更 DB 再删缓存(延迟双删)");
    }

    /** Cache-Aside 读路径(无任何防护,用于演示穿透) */
    static String cacheAsideGet(String key) {
        String cached = CACHE.get(key);
        if (cached != null) return cached;
        dbHits++;
        String value = DB.get(key);          // 回源 DB(不存在返回 null)
        if (value != null) CACHE.put(key, value); // 命中才写回缓存
        return value;
    }

    /** 带互斥锁的读:防击穿(Double-Check + 单例锁,只让一个线程回源) */
    static String getWithMutex(String key) {
        String cached = CACHE.get(key);
        if (cached != null) return cached;
        synchronized (CacheAsideSimulation.class) {
            cached = CACHE.get(key);         // Double-Check:别的线程可能已重建
            if (cached == null) {
                dbHits++;
                try { Thread.sleep(10); } catch (InterruptedException ignored) {} // 模拟慢 DB
                String value = DB.get(key);
                if (value != null) CACHE.put(key, value);
                return value;
            }
        }
        return cached;
    }

    /** 雪崩演示:批量 key 用固定 TTL vs 随机 TTL,看同一时刻过期数量 */
    static void demoAvalanche(boolean randomTtl) {
        final int n = 1000;
        int expireSameMoment = 0;
        final long baseTtl = 1000;
        final long[] ttls = new long[n];
        for (int i = 0; i < n; i++) {
            ttls[i] = randomTtl ? baseTtl + RANDOM.nextInt(500) : baseTtl; // 随机抖动 0~500ms
        }
        // 统计命中"同一个 TTL 时刻"的数量:固定 TTL 全部撞在同一秒
        long target = baseTtl;
        for (long t : ttls) if (t == target) expireSameMoment++;
        System.out.println("  " + (randomTtl ? "随机 TTL" : "固定 TTL")
                + ":同时(同一时刻)过期的 key 数 = " + expireSameMoment + " / " + n
                + (randomTtl ? "(抖动打散,雪崩风险低)" : "(全部撞车,DB 被瞬间打爆)"));
    }

    /** 双写一致性:演示两种更新顺序的窗口期 */
    static void demoDoubleWrite() {
        // 场景:缓存有旧值 V1,DB 将更新为 V2
        CACHE.put("goods:1", "V1_old");
        DB.put("goods:1", "V2_new");

        // 错误顺序:先删缓存 → 更新 DB 的间隙,读线程把旧值写回缓存
        System.out.println("  [先删缓存后更 DB]");
        CACHE.remove("goods:1");                    // 1. 删缓存
        // —— 窗口期:读线程 T2 读到 DB 旧值 V1(DB 还没更新)——
        String stale = DB.get("goods:1");           // 模拟 T2 此刻回源(DB 还是 V1?不,DB 已 V2)
        // 更真实的窗口:把"删缓存"和"更 DB"拆开,中间让读线程回源旧值
        CACHE.put("goods:1", "V1_stale_from_reader"); // T2 写回旧值
        DB.put("goods:1", "V2_new");
        System.out.println("  缓存=" + CACHE.get("goods:1") + " DB=" + DB.get("goods:1")
                + " → 不一致(旧值被读线程写回,需延迟双删兜底)");

        // 正确顺序:先更 DB → 再删缓存
        System.out.println("  [先更 DB 后删缓存]");
        DB.put("goods:2", "V2_new");
        CACHE.remove("goods:2");                    // 删除缓存,下次读回源新值
        System.out.println("  缓存=" + CACHE.get("goods:2") + "(Miss)DB=" + DB.get("goods:2")
                + " → 下次读回源即新值,不一致窗口极短");
    }
}

站点构建时间:2026/8/24 23:43:17