起因
前几天在掘金看到一篇文章《延迟双删如此好用,为何大厂从来不用》,文章里提到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);
}
当两个线程并发操作时:
关键问题:线程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);
}
这个方案的思路是:通过第二次删除,把可能被写入的旧数据再清理掉。但问题在于:
- 延迟时间难以确定:设置多少合适?500ms?1000ms?
- 如果查询时间超过延迟时间,问题依然存在
- 缺乏理论保证
所以需要一个理论上能够证明正确性的方案。
Facebook租约机制
基本原理
租约机制的核心思想很简单:给每个想要写缓存的线程发一个"通行证",写入时检查通行证是否还有效。如果缓存被删除了,通行证就失效了,持有旧通行证的线程无法写入。
数据结构设计
在Redis中,使用Hash结构存储三个字段:
java
// key: product:1001
// Hash结构:
{
"value": "商品数据", // 实际缓存的内容
"lockUntil": "1702123456", // 租约过期时间戳
"lockOwner": "uuid-abc-123" // 租约持有者的唯一标识
}
工作流程
关键点:第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);
}
执行过程:
测试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");
}
流程图:
测试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的租约机制,我理解了几个关键点:
-
原子性是核心:使用Lua脚本保证"检查+修改"的原子性,避免竞态条件
-
租约是写入许可:只有持有有效租约的线程才能写缓存,旧租约失效后无法写入
-
tagAsDeleted的本质:不是删除数据,而是作废所有租约,让持有旧租约的线程无法写入
-
弱一致性和强一致性的取舍:弱一致性用短暂的不一致换取性能,强一致性保证最新但牺牲响应速度
-
批量操作的价值:通过减少网络往返,在微服务场景下能带来数量级的性能提升
相比延迟双删,租约机制有理论保证,实现也更优雅。这次通过代码实现,把这个问题算是研究透了。
如果需要完整源码,请在评论区留言。