缓存一致性的工业级解法:用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. 批量操作的价值:通过减少网络往返,在微服务场景下能带来数量级的性能提升

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

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

相关推荐
2601_957884846 小时前
深度拆解:大模型RAG架构下,GEO优化的技术实现路径
人工智能·架构
㳺三才人子6 小时前
初探 Flask
后端·python·flask·html
星栈独行6 小时前
我在 Rust 全栈项目里用 JWT 做无状态认证
开发语言·后端·rust·前端框架·开源·github·web
Java爱好狂.7 小时前
Java程序员体系化学习路线(2026最新版)
java·后端·java面试·java架构师·java程序员·java八股文·java学习路线
陈随易7 小时前
Redis 8.8发布,一定要更新
前端·后端·程序员
装不满的克莱因瓶7 小时前
SpringBoot 如何将 lib 目录中jar包打包进最终的jar包里面
spring boot·后端·maven·jar·mvn
Raink老师7 小时前
【AI面试临阵磨枪-62】设计基于 RAG 的内部知识库问答平台(多租户、权限、文件上传、实时更新)
人工智能·面试·职场和发展
ltl8 小时前
Transformer 原论文实验结果:为什么 28.4 BLEU 足以改写路线图
后端
Curvatureflight8 小时前
【架构实战】生产级大模型 API 接入指南:流式响应(Streaming)异常处理与监控闭环
python·架构
这是谁的博客?8 小时前
微服务架构设计模式深度解析:从拆分策略到容灾机制
微服务·设计模式·云原生·架构·架构设计·后端开发·分布式系统