缓存一致性的工业级解法:用Java实现Facebook租约机制

起因

前几天在掘金看到一篇文章《延迟双删如此好用,为何大厂从来不用》,文章里提到Facebook在2013年NSDI论文中提出的租约机制才是大厂真正在用的方案。这勾起了我的好奇心,决定深入研究一下这个机制到底是怎么回事。

研究过程中发现了dtm-labs的rockscache项目,是Go语言实现。为了更好地理解原理,我用Java重写了一遍,基于Redis + Lettuce实现。这篇文章记录下我的研究过程和理解。

问题场景:先更新数据库,再删缓存

先看看常见的缓存更新策略会遇到什么问题。

并发场景分析

假设我们有这样的代码:

java 复制代码
// 查询商品
public Product getProduct(String id) {
    Product product = redis.get(id);
    if (product != null) {
        return product;
    }
    
    product = database.query(id);
    redis.set(id, product);
    return product;
}

// 更新商品
public void updateProduct(Product product) {
    database.update(product);
    redis.delete(product.id);
}

当两个线程并发操作时:

sequenceDiagram participant A as 线程A(查询) participant Cache as Redis缓存 participant DB as 数据库 participant B as 线程B(更新) A->>Cache: 1. 查询商品价格 Cache-->>A: 返回null(未命中) Note over B: 2. 业务更新 B->>DB: 更新价格 5999→4999 B->>Cache: 3. 删除缓存 A->>DB: 4. 查询数据库 DB-->>A: 返回5999(旧值) A->>Cache: 5. 写入缓存 Note over Cache: 缓存:5999(旧数据) Note over DB: 数据库:4999(新数据) Note right of Cache: 缓存和数据库不一致

关键问题:线程A在第1步获取了查询权限,但在第5步才写入,此时缓存已经在第3步被删除了。这导致旧数据覆盖了缓存。

延迟双删的方案

延迟双删是一个常见的优化方案:

java 复制代码
public void updateProduct(Product product) {
    database.update(product);
    redis.delete(product.id);
    
    // 延迟一段时间后再删一次
    asyncExecutor.schedule(() -> {
        redis.delete(product.id);
    }, 500, TimeUnit.MILLISECONDS);
}

这个方案的思路是:通过第二次删除,把可能被写入的旧数据再清理掉。但问题在于:

  1. 延迟时间难以确定:设置多少合适?500ms?1000ms?
  2. 如果查询时间超过延迟时间,问题依然存在
  3. 缺乏理论保证

所以需要一个理论上能够证明正确性的方案。

Facebook租约机制

基本原理

租约机制的核心思想很简单:给每个想要写缓存的线程发一个"通行证",写入时检查通行证是否还有效。如果缓存被删除了,通行证就失效了,持有旧通行证的线程无法写入。

数据结构设计

在Redis中,使用Hash结构存储三个字段:

java 复制代码
// key: product:1001
// Hash结构:
{
    "value": "商品数据",           // 实际缓存的内容
    "lockUntil": "1702123456",    // 租约过期时间戳
    "lockOwner": "uuid-abc-123"   // 租约持有者的唯一标识
}

工作流程

sequenceDiagram participant A as 线程A participant Lua as Lua脚本 participant Redis as Redis participant DB as 数据库 participant B as 线程B A->>Lua: 1. fetch(key) Lua->>Redis: 读取value和lockUntil Redis-->>Lua: null, null Lua->>Redis: 设置lockOwner="A" Lua-->>A: 返回{null, LOCKED} Note over A: 2. 持有租约A,查询数据库 B->>DB: 3. 更新数据库 B->>Lua: 4. tagAsDeleted(key) Lua->>Redis: lockUntil=0, 删除lockOwner A->>DB: 5. 查询完成,得到旧值 A->>Lua: 6. set(key, value, owner="A") Lua->>Redis: 检查lockOwner Redis-->>Lua: lockOwner不存在 Lua-->>A: 写入失败 Note over Redis: 缓存保持空状态 Note over Redis: 下次查询会重新加载新数据

关键点:第6步写入时,会检查租约持有者。由于第4步已经删除了lockOwner,所以写入会被拒绝,避免了旧数据污染缓存。

代码实现

Lua脚本:原子性保证

为什么要用Lua脚本?因为需要保证"读取→判断→修改"这个过程的原子性。如果分开执行,在读取和修改之间,其他线程可能修改数据。

脚本1:获取数据并发放租约

lua 复制代码
-- GET_SCRIPT
-- KEYS[1]: 缓存key
-- ARGV[1]: 当前时间戳
-- ARGV[2]: 租约到期时间
-- ARGV[3]: 租约持有者ID

local value = redis.call('HGET', KEYS[1], 'value')
local lockUntil = redis.call('HGET', KEYS[1], 'lockUntil')

-- 判断是否需要重新加载
-- 条件1:缓存不存在
-- 条件2:租约已过期
if (lockUntil == false and value == false) or 
   (lockUntil ~= false and tonumber(lockUntil) < tonumber(ARGV[1])) then
   
    -- 发放新租约
    redis.call('HSET', KEYS[1], 'lockUntil', ARGV[2])
    redis.call('HSET', KEYS[1], 'lockOwner', ARGV[3])
    
    return {value, 'LOCKED'}
end

return {value, lockUntil}

脚本2:检查租约并写入

lua 复制代码
-- SET_SCRIPT
-- KEYS[1]: 缓存key
-- ARGV[1]: 要写入的值
-- ARGV[2]: 租约持有者ID
-- ARGV[3]: 过期时间(秒)

local owner = redis.call('HGET', KEYS[1], 'lockOwner')

-- 检查租约所有权
if owner ~= ARGV[2] then
    return 0  -- 租约不匹配,拒绝写入
end

-- 写入数据
redis.call('HSET', KEYS[1], 'value', ARGV[1])

-- 清除租约(用完即销毁)
redis.call('HDEL', KEYS[1], 'lockOwner')
redis.call('HDEL', KEYS[1], 'lockUntil')

-- 设置过期时间
redis.call('EXPIRE', KEYS[1], ARGV[3])

return 1

脚本3:标记删除

lua 复制代码
-- DELETE_SCRIPT
-- KEYS[1]: 缓存key
-- ARGV[1]: 延迟删除时间(秒)

-- 设置租约立即过期
redis.call('HSET', KEYS[1], 'lockUntil', 0)

-- 删除租约持有者
redis.call('HDEL', KEYS[1], 'lockOwner')

-- 延迟删除(给正在进行的查询一些时间)
redis.call('EXPIRE', KEYS[1], ARGV[1])

Java客户端实现

核心方法的实现逻辑:

java 复制代码
public class RocksCacheClient {
    
    private final RedisClient redisClient;
    private final RocksCacheOptions options;
    private final SingleFlight<String> singleFlight;
    
    // 单键查询
    public String fetch(String key, Duration expire, DataLoader<String> loader) {
        // 使用SingleFlight防止缓存击穿
        return singleFlight.execute(key, () -> {
            if (options.isStrongConsistency()) {
                return strongFetch(key, expire, loader);
            } else {
                return weakFetch(key, expire, loader);
            }
        });
    }
    
    // 弱一致性实现
    private String weakFetch(String key, Duration expire, DataLoader<String> loader) {
        String owner = generateOwner();
        List<Object> result = luaGet(key, owner);
        
        // 等待其他线程释放锁
        while (result.get(0) == null && !LOCKED.equals(result.get(1))) {
            sleep(options.getLockSleep());
            result = luaGet(key, owner);
        }
        
        String value = (String) result.get(0);
        Object lockStatus = result.get(1);
        
        // 缓存命中
        if (!LOCKED.equals(lockStatus)) {
            return value;
        }
        
        // 缓存未命中,但有旧值
        if (value != null) {
            // 异步更新,立即返回旧值
            CompletableFuture.runAsync(() -> 
                fetchNew(key, expire, owner, loader)
            );
            return value;
        }
        
        // 无旧值,同步加载
        return fetchNew(key, expire, owner, loader);
    }
    
    // 强一致性实现
    private String strongFetch(String key, Duration expire, DataLoader<String> loader) {
        String owner = generateOwner();
        List<Object> result = luaGet(key, owner);
        
        // 等待直到获取锁或缓存可用
        while (result.get(1) != null && !LOCKED.equals(result.get(1))) {
            sleep(options.getLockSleep());
            result = luaGet(key, owner);
        }
        
        if (!LOCKED.equals(result.get(1))) {
            return (String) result.get(0);
        }
        
        // 同步加载
        return fetchNew(key, expire, owner, loader);
    }
    
    // 加载新数据
    private String fetchNew(String key, Duration expire, String owner, DataLoader<String> loader) {
        try {
            String value = loader.load();
            if (value == null) {
                value = "";
            }
            luaSet(key, value, expire, owner);
            return value;
        } catch (Exception e) {
            luaUnlock(key, owner);
            throw new RocksCacheException("Failed to load data", e);
        }
    }
    
    // 标记删除
    public void tagAsDeleted(String key) {
        if (options.isDisableCacheDelete()) {
            return;
        }
        luaDelete(key);
    }
}

测试用例分析

测试1:基本查询流程

java 复制代码
@Test
void testBasicFetch() {
    String key = "test:basic";
    String expected = "value1";
    
    // 第一次查询:缓存未命中
    String result = client.fetch(key, Duration.ofSeconds(60), () -> expected);
    assertThat(result).isEqualTo(expected);
    
    // 验证数据已缓存
    String cached = client.rawGet(key);
    assertThat(cached).isEqualTo(expected);
}

执行过程:

sequenceDiagram participant Client participant Lua participant Redis participant Loader Client->>Lua: fetch(key) Lua->>Redis: HGET value, lockUntil Redis-->>Lua: null, null Lua->>Redis: HSET lockOwner=uuid Lua-->>Client: {null, LOCKED} Client->>Loader: load() Loader-->>Client: "value1" Client->>Lua: set(key, value, owner) Lua->>Redis: 检查owner Lua->>Redis: HSET value="value1" Lua->>Redis: HDEL lockOwner

测试2:缓存命中

java 复制代码
@Test
void testFetchCacheHit() {
    String key = "test:hit";
    
    // 预先填充缓存
    client.rawSet(key, "cached-value", Duration.ofSeconds(60));
    
    AtomicInteger callCount = new AtomicInteger(0);
    String result = client.fetch(key, Duration.ofSeconds(60), () -> {
        callCount.incrementAndGet();
        return "should-not-be-called";
    });
    
    assertThat(result).isEqualTo("cached-value");
    assertThat(callCount.get()).isEqualTo(0);  // loader未被调用
}

测试3:弱一致性模式

java 复制代码
@Test
void testTagAsDeleted() throws Exception {
    String key = "test:delete";
    
    // 第一次加载
    String result1 = client.fetch(key, Duration.ofSeconds(60), () -> "value1");
    assertThat(result1).isEqualTo("value1");
    
    // 标记删除
    client.tagAsDeleted(key);
    
    // 立即查询 - 返回旧值(弱一致性)
    String result2 = client.fetch(key, Duration.ofSeconds(60), () -> "value2");
    assertThat(result2).isEqualTo("value1");  // 返回旧值,不阻塞
    
    // 等待异步更新完成
    Thread.sleep(300);
    
    // 再次查询 - 已更新为新值
    String result3 = client.fetch(key, Duration.ofSeconds(60), () -> "should-not-call");
    assertThat(result3).isEqualTo("value2");
}

流程图:

sequenceDiagram participant Test participant Client participant Redis participant Async Test->>Client: fetch() 第一次 Client->>Redis: 加载并缓存 "value1" Test->>Client: tagAsDeleted() Client->>Redis: lockUntil=0, 删除lockOwner Test->>Client: fetch() 第二次 Client->>Redis: 发现lockUntil=0 Note over Client: 有旧值,立即返回 Client-->>Test: 返回 "value1" Client->>Async: 启动异步更新 Async->>Redis: 加载 "value2" Note over Test: sleep(300ms) Test->>Client: fetch() 第三次 Client->>Redis: 读取缓存 Redis-->>Client: "value2" Client-->>Test: 返回 "value2"

测试4:强一致性模式

java 复制代码
@Test
void testStrongConsistency() throws Exception {
    client.close();
    RocksCacheOptions strongOptions = RocksCacheOptions.builder()
            .strongConsistency(true)
            .build();
    client = new RocksCacheClient(redisClient, strongOptions);
    
    client.fetch(key, Duration.ofSeconds(60), () -> "value1");
    client.tagAsDeleted(key);
    
    Thread.sleep(100);
    
    // 强一致性:等待新数据加载完成
    long startTime = System.currentTimeMillis();
    String result = client.fetch(key, Duration.ofSeconds(60), () -> {
        try {
            Thread.sleep(150);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "value2";
    });
    long elapsed = System.currentTimeMillis() - startTime;
    
    assertThat(result).isEqualTo("value2");
    assertThat(elapsed).isGreaterThanOrEqualTo(150);  // 确实等待了
}

对比:

模式 响应时间 返回结果 适用场景
弱一致性 1ms 可能是旧值 商品详情、用户资料
强一致性 150ms 保证最新 订单状态、账户余额

SingleFlight:防止缓存击穿

当缓存失效时,大量并发请求会同时查询数据库。SingleFlight的作用是让这些请求共享结果。

java 复制代码
public class SingleFlight<T> {
    private final Map<String, CompletableFuture<T>> calls = new ConcurrentHashMap<>();
    private final Map<String, Lock> locks = new ConcurrentHashMap<>();
    
    public T execute(String key, Supplier<T> supplier) throws Exception {
        // 快速检查:是否已有正在执行的请求
        CompletableFuture<T> existingCall = calls.get(key);
        if (existingCall != null) {
            return existingCall.get();
        }
        
        // 获取锁,再次检查
        Lock lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
        lock.lock();
        try {
            existingCall = calls.get(key);
            if (existingCall != null) {
                lock.unlock();
                return existingCall.get();
            }
            
            // 创建新的请求
            CompletableFuture<T> newCall = new CompletableFuture<>();
            calls.put(key, newCall);
            lock.unlock();
            
            try {
                T result = supplier.get();
                newCall.complete(result);
                return result;
            } finally {
                calls.remove(key);
                locks.remove(key);
            }
        } catch (Exception e) {
            lock.unlock();
            throw e;
        }
    }
}

测试验证:

java 复制代码
@Test
void testSingleFlight() throws Exception {
    String key = "test:sf";
    AtomicInteger dbQueryCount = new AtomicInteger(0);
    
    // 启动100个并发请求
    List<CompletableFuture<String>> futures = new ArrayList<>();
    for (int i = 0; i < 100; i++) {
        futures.add(CompletableFuture.supplyAsync(() ->
            client.fetch(key, Duration.ofSeconds(60), () -> {
                dbQueryCount.incrementAndGet();
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return "value";
            })
        ));
    }
    
    // 等待所有完成
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();
    
    // 验证:数据库只被查询1次
    assertThat(dbQueryCount.get()).isEqualTo(1);
}

批量操作优化

批量操作可以显著减少网络往返次数:

java 复制代码
@Test
void testBatchFetchPartialHit() {
    String[] keys = {"batch:p1", "batch:p2", "batch:p3"};
    
    // 预先缓存部分数据
    client.rawSet(keys[0], "cached0", Duration.ofSeconds(60));
    client.rawSet(keys[2], "cached2", Duration.ofSeconds(60));
    
    AtomicInteger callCount = new AtomicInteger(0);
    
    Map<Integer, String> result = client.fetchBatch(keys, Duration.ofSeconds(60), 
        indices -> {
            // indices只包含未命中的索引:[1]
            Map<Integer, String> data = new HashMap<>();
            for (int idx : indices) {
                callCount.incrementAndGet();
                data.put(idx, "fetched" + idx);
            }
            return data;
        }
    );
    
    // 结果合并了缓存和新查询
    assertThat(result.get(0)).isEqualTo("cached0");   // 来自缓存
    assertThat(result.get(1)).isEqualTo("fetched1");  // 新查询
    assertThat(result.get(2)).isEqualTo("cached2");   // 来自缓存
    
    // 只查询了1次
    assertThat(callCount.get()).isEqualTo(1);
}

批量脚本:

lua 复制代码
-- GET_BATCH_SCRIPT
local rets = {}
for i, key in ipairs(KEYS) do
    local v = redis.call('HGET', key, 'value')
    local lu = redis.call('HGET', key, 'lockUntil')
    
    if (lu == false and v == false) or 
       (lu ~= false and tonumber(lu) < tonumber(ARGV[1])) then
        redis.call('HSET', key, 'lockUntil', ARGV[2])
        redis.call('HSET', key, 'lockOwner', ARGV[3])
        table.insert(rets, {v, 'LOCKED'})
    else
        table.insert(rets, {v, lu})
    end
end
return rets

性能对比:

操作 网络往返 耗时
单键查询100次 100次 100ms
批量查询100个 1次 3ms

配置项说明

java 复制代码
RocksCacheOptions options = RocksCacheOptions.builder()
    .delay(Duration.ofSeconds(10))              // 延迟删除时间
    .emptyExpire(Duration.ofSeconds(60))        // 空值缓存时间
    .lockExpire(Duration.ofSeconds(3))          // 租约过期时间
    .lockSleep(Duration.ofMillis(100))          // 等待锁的休眠间隔
    .randomExpireAdjustment(0.1)                // 过期时间随机因子(防雪崩)
    .strongConsistency(false)                   // 是否强一致性
    .disableCacheRead(false)                    // 降级:禁用缓存读
    .disableCacheDelete(false)                  // 降级:禁用缓存删除
    .waitReplicas(0)                            // 等待Redis主从复制的从节点数
    .build();

参数说明:

  • delay:延迟删除时间。tagAsDeleted时不会立即删除数据,而是给正在查询的线程一些完成时间
  • emptyExpire:空值缓存时间。防止缓存穿透(查询不存在的数据)
  • lockExpire:租约过期时间。应设置为数据加载的最大耗时
  • randomExpireAdjustment:过期时间随机因子。例如0.1表示在原过期时间的90%-100%之间随机,防止缓存雪崩

总结

通过研究Facebook的租约机制,我理解了几个关键点:

  1. 原子性是核心:使用Lua脚本保证"检查+修改"的原子性,避免竞态条件

  2. 租约是写入许可:只有持有有效租约的线程才能写缓存,旧租约失效后无法写入

  3. tagAsDeleted的本质:不是删除数据,而是作废所有租约,让持有旧租约的线程无法写入

  4. 弱一致性和强一致性的取舍:弱一致性用短暂的不一致换取性能,强一致性保证最新但牺牲响应速度

  5. 批量操作的价值:通过减少网络往返,在微服务场景下能带来数量级的性能提升

相比延迟双删,租约机制有理论保证,实现也更优雅。这次通过代码实现,把这个问题算是研究透了。

如果需要完整源码,请在评论区留言。

相关推荐
remaindertime1 天前
一文掌握 Spring AI:集成主流大模型的完整方案与思考
后端·spring·ai编程
Lear1 天前
【JavaSE】多态深度解析:从核心概念到最佳实践
后端
weixin_307779131 天前
Jenkins jsoup API 插件:强大的 HTML 解析底层支持与使用指南
运维·前端·架构·html·jenkins
血小溅1 天前
SpringBoot 整合 QLExpress 教程:患者随访画像多条件适配案例
后端
HelloReader1 天前
从 Rocket 0.4 升级到 0.5一份实战迁移指南
后端·rust
ChrisitineTX1 天前
Spring Boot 3 + GraalVM Native Image 原理:从启动 10秒 到 0.05秒,AOT 编译到底干了什么?
java·spring boot·后端
C雨后彩虹1 天前
数组二叉树
java·数据结构·算法·华为·面试
CodeSheep1 天前
华为又招天才少年了。。
前端·后端·程序员
武子康1 天前
大数据-179 Elasticsearch 倒排索引与读写流程全解析:从 Lucene 原理到 Query/Fetch 实战
大数据·后端·elasticsearch