Files
thumb-up/docs/技术调研文档.md
wonder 1dda7e80b9 ✨Feat: 实现缓存穿透/击穿防护 + 分布式锁
- 新增 BloomFilterManager:Guava 布隆过滤器(100万容量,1%误判率)
- 新增 RedisLockUtil:基于 Redis SETNX + Lua 脚本的分布式锁
- 改造 CacheManager:添加空值短缓存(30秒TTL)+ 互斥锁防击穿(double-check)
- 改造 ThumbServiceImpl:使用分布式锁替代 synchronized,集成布隆过滤器
- 新增 docs/技术调研文档.md:完整技术调研文档
- 更新 README.md:添加技术文档索引
2026-09-09 20:04:52 +08:00

26 KiB
Raw Permalink Blame History

高并发点赞系统 — 技术调研文档

本文档基于 thumb-up 项目源码,逐项解析五个核心技术点的实现状态、实现链路、方案选型依据、现存漏洞及完善方向。

更新说明:原调研中识别的未实现项(布隆过滤器、空值短缓存、互斥锁防击穿、分布式锁)已全部实现并更新文档。


目录

  1. 缓存穿透与缓存击穿防护
  2. Caffeine + Redis 二级缓存
  3. Lua 脚本原子性 + 时间片分桶 + 定时落库 + 补偿兜底
  4. HeavyKeeper Top-K 探测 vs CMS
  5. 单机锁与分布式锁

1. 缓存穿透与缓存击穿防护

1.1 实现状态:✅ 已实现

三重防护机制已全部实现:

防护机制 实现文件 状态
布隆过滤器 BloomFilterManager.java ✅
空值短缓存 CacheManager.java ✅
互斥锁防击穿 CacheManager.java ✅

1.2 实现链路

1.2.1 布隆过滤器

文件:BloomFilterManager.java

  • 选型:Guava BloomFilter,预期插入量 100万,误判率 1%
  • 初始化:@PostConstruct 启动时创建
  • 写入时机:ThumbServiceImpl.doThumb() 点赞成功后调用 bloomFilterManager.add(userId, blogId)
  • 判断时机:ThumbServiceImpl.hasThumb() 查询前调用 bloomFilterManager.mightContain(userId, blogId)
// BloomFilterManager.java
@Component
public class BloomFilterManager {
    private BloomFilter<String> thumbBloomFilter;

    @PostConstruct
    public void init() {
        thumbBloomFilter = BloomFilter.create(
                Funnels.stringFunnel(StandardCharsets.UTF_8),
                1_000_000L,  // 预期插入量
                0.01          // 误判率 1%
        );
    }

    public void add(Long userId, Long blogId) {
        thumbBloomFilter.put(userId + ":" + blogId);
    }

    public boolean mightContain(Long userId, Long blogId) {
        return thumbBloomFilter.mightContain(userId + ":" + blogId);
    }
}

拦截流程:

请求 hasThumb(userId, blogId)
  → bloomFilterManager.mightContain()
    → false(一定不存在)→ 直接返回 false,不查 Redis
    → true(可能存在)→ 继续查询二级缓存

1.2.2 空值短缓存

实现位置:CacheManager.get() 方法

  • 使用独立的 Caffeine 实例(nullValueCache),TTL 30秒,最大 10000 条目
  • 当 Redis 查询返回 null 时,写入空值标记到 nullValueCache
  • 后续请求命中空值短缓存直接返回 null,30秒后自动过期
// CacheManager.java
private final Cache<String, Object> nullValueCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(30, TimeUnit.SECONDS)  // 30秒短缓存
        .build();

public Object get(String hashKey, String key) {
    // ... 1. 查本地缓存(L1)

    // 2. 查空值短缓存(防缓存穿透)
    Object nullMarker = nullValueCache.getIfPresent(compositeKey);
    if (nullMarker != null) {
        return null;  // 空值短缓存命中,避免穿透到 Redis
    }

    // ... 3. 加互斥锁 → 4. 查 Redis
    if (redisValue == null) {
        nullValueCache.put(compositeKey, NULL_PLACEHOLDER);  // 写入空值短缓存
        return null;
    }
    // ...
}

1.2.3 互斥锁防缓存击穿

实现位置:CacheManager.get() 方法

  • 使用 ConcurrentHashMap<String, Object> 管理锁对象(避免 intern() 内存泄漏)
  • 锁粒度为 compositeKey(hashKey:key),确保同一 Key 只有一个线程回源
  • 采用 double-check 模式:加锁后再次检查本地缓存
// CacheManager.java
private final ConcurrentHashMap<String, Object> lockMap = new ConcurrentHashMap<>();

public Object get(String hashKey, String key) {
    // 1. 查本地缓存
    // 2. 查空值短缓存

    // 3. 加互斥锁,防止缓存击穿
    Object lock = lockMap.computeIfAbsent(compositeKey, k -> new Object());
    synchronized (lock) {
        // double-check:再次检查本地缓存
        value = localCache.getIfPresent(compositeKey);
        if (value != null) return value;

        // 查 Redis...
        // 热 Key 提升到本地缓存...
    }
}

1.3 方案选型依据

维度 说明
为什么用 Guava BloomFilter Java 生态最成熟的布隆过滤器实现,API 简洁,性能优秀
为什么空值缓存用独立实例 与正常缓存隔离,避免空值污染正常缓存的淘汰策略
为什么空值 TTL 30秒 平衡穿透防护效果与数据一致性——太短则防护效果差,太长则数据变更后延迟过大
为什么用 ConcurrentHashMap 管理锁 避免 String.intern() 内存泄漏,支持锁对象的复用和清理

1.4 现存漏洞

漏洞 说明
缓存雪崩 所有本地缓存 Key 的 TTL 均为固定 5 分钟,存在同时过期的风险(可考虑加随机偏移)
lockMap 无限膨胀 lockMap 中的锁对象不会自动清理,长期运行后可能占用较多内存
布隆过滤器假阳性 1% 误判率意味着少量不存在的 Key 会穿透到 Redis,但不会漏判真正的热 Key

2. Caffeine + Redis 二级缓存

2.1 实现状态:✅ 已实现

2.2 实现链路

架构概览:

请求 → CacheManager.get()
         ├── L1: Caffeine 本地缓存(命中 → 直接返回)
         └── L2: Redis Hash(命中 → 记录访问 → 判断是否热Key → 可能提升到L1)

核心代码:CacheManager.java

L1 本地缓存配置:

// CacheManager.java:39-44
Caffeine.newBuilder()
    .maximumSize(1000)                    // 最大 1000 个条目
    .expireAfterWrite(5, TimeUnit.MINUTES) // 写入后 5 分钟过期
    .build();

二级查询逻辑(get 方法):

  1. 先查 L1(Caffeine)→ 命中则记录访问并返回
  2. L1 未命中 → 查 L2(Redis Hash)
  3. Redis 也未命中 → 返回 null
  4. Redis 命中 → 通过 HeavyKeeper 记录访问频率
  5. 若 Key 被判定为热 Key(进入 Top-100 且访问次数 ≥ 10)→ 提升到 L1
// CacheManager.java:52-77
public Object get(String hashKey, String key) {
    String compositeKey = this.buildCacheKey(hashKey, key);
    // 1. 查本地缓存
    Object value = localCache.getIfPresent(compositeKey);
    if (value != null) {
        hotKeyDetector.add(key, 1);  // 记录访问
        return value;
    }
    // 2. 查 Redis
    Object redisValue = redisTemplate.opsForHash().get(hashKey, key);
    if (redisValue == null) {
        return null;
    }
    // 3. 记录访问次数,判断是否热 Key
    AddResult addResult = hotKeyDetector.add(key, 1);
    // 4. 热 Key 提升到本地缓存
    if (addResult.isHotKey()) {
        localCache.put(compositeKey, redisValue);
    }
    return redisValue;
}

写一致性(putIfPresent 方法):

// CacheManager.java:79-86
public void putIfPresent(String hashKey, String key, Object value) {
    String compositeKey = this.buildCacheKey(hashKey, key);
    Object object = localCache.getIfPresent(compositeKey);
    if (object == null) return;  // 仅更新已存在的 Key,避免冷数据污染
    localCache.put(compositeKey, value);
}

热 Key 淘汰:

// CacheManager.java:89-92
@Scheduled(fixedRate = 20, timeUnit = TimeUnit.SECONDS)
public void cleanHotKeys() {
    hotKeyDetector.fading();  // 每 20 秒对所有计数器右移一位(减半)
}

2.3 方案选型依据

维度 说明
为什么用 Caffeine Java 生态最优的本地缓存库,Window TinyLfu 淘汰策略命中率高于 LRU,性能优于 Guava Cache
为什么不用 Spring @Cacheable 项目需要精细化控制(热 Key 判断、条件性提升),@Cacheable 注解方式不够灵活
为什么用 HeavyKeeper 而非固定阈值 固定阈值无法适应流量波动;HeavyKeeper 基于概率衰减,能自适应调整热 Key 集合
为什么 putIfPresent 而非 put 避免冷数据污染本地缓存——只有已经被提升到 L1 的 Key 才会被更新

2.4 现存漏洞

漏洞 说明
L1/L2 数据不一致 ThumbServiceImpl.undoThumb() 中先删 Redis 再更新 L1,若中间进程崩溃,L1 仍持有旧值
缓存穿透 Redis 返回 null 时未缓存空值(已在第1节详述)
本地缓存容量固定 maximumSize(1000) 在高并发场景下可能不足,应根据实际 QPS 动态调整
HeavyKeeper 线程安全 add() 方法中 total += increment(第84行)非原子操作,高并发下存在竞态条件
fading 期间锁竞争 fading() 遍历 50万个 Bucket 并逐个加锁,可能造成短暂的性能抖动

2.5 完善方向

  • 为 total 字段改用 AtomicLong 或在 fading() 中统一加锁
  • 考虑引入 refreshAfterWrite 替代 expireAfterWrite,实现异步刷新而非同步淘汰
  • 在 CacheManager 中增加缓存命中率监控指标(Micrometer/Prometheus)

3. Lua 脚本原子性 + 时间片分桶 + 定时落库 + 补偿兜底

3.1 实现状态:✅ 已实现

3.2 实现链路

整体架构:

用户点赞 → Lua脚本原子操作 → Redis临时分桶(temp_thumb:HH:mm:SS)
                                    ↓ (每10秒)
                              SyncThumb2DBJob 批量落库 → MySQL
                                    ↓ (每天凌晨2点)
                              CompensatoryJob 补偿兜底

3.2.1 Lua 脚本保证原子性

文件:RedisLuaScriptConstant.java

点赞脚本(THUMB_SCRIPT):

-- KEYS[1] = temp_thumb:{timeSlice}  -- 临时计数键
-- KEYS[2] = thumb:{userId}          -- 用户点赞状态键
-- ARGV[1] = userId
-- ARGV[2] = blogId

-- 1. 检查是否已点赞
if redis.call('HEXISTS', userThumbKey, blogId) == 1 then
    return -1  -- 已点赞
end
-- 2. 获取旧值
local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)
-- 3. 原子更新:临时计数 + 用户状态
redis.call('HSET', tempThumbKey, hashKey, oldNumber + 1)
redis.call('HSET', userThumbKey, blogId, 1)
return 1

取消点赞脚本(UNTHUMB_SCRIPT):逻辑对称,先检查存在性,再递减计数并删除状态标记。

原子性保证:Redis 单线程执行 Lua 脚本期间不会被其他命令打断,确保「检查 + 计数更新 + 状态标记」三步操作的原子性。

3.2.2 10 秒时间片分桶

文件:ThumbServiceRedisImpl.java

// ThumbServiceRedisImpl.java:108-111
private String getTimeSlice() {
    DateTime nowDate = DateUtil.date();
    return DateUtil.format(nowDate, "HH:mm:" + (DateUtil.second(nowDate) / 10) * 10);
}
  • 将时间划分为 10 秒粒度的桶:14:35:00、14:35:10、14:35:20...
  • 每个桶对应一个 Redis Hash Key:temp_thumb:14:35:10
  • Hash 中的 field 为 userId:blogId,value 为 1(点赞)或 -1(取消点赞)
  • 设计意图:避免高频写入直接打 MySQL,通过分桶聚合增量

3.2.3 定时任务批量落库

文件:SyncThumb2DBJob.java

@Scheduled(fixedRate = 10000)  // 每 10 秒执行
@Transactional(rollbackFor = Exception.class)
public void run() {
    // 计算上一个 10 秒时间窗口(避免读取正在写入的桶)
    int second = (DateUtil.second(nowDate) / 10 - 1) * 10;
    // ... 读取 temp_thumb:{date} 全部数据
    // 批量插入点赞记录
    thumbService.saveBatch(thumbList);
    // 批量删除取消点赞记录
    thumbService.remove(wrapper);
    // 批量更新博客点赞计数
    blogMapper.batchUpdateThumbCount(blogThumbCountMap);
    // 异步删除临时 Key
    Thread.startVirtualThread(() -> redisTemplate.delete(tempThumbKey));
}

关键设计:

  • 处理上一个时间窗口的数据,避免与当前写入冲突
  • 使用 CASE WHEN 批量更新博客计数(单条 SQL)
  • 使用 Java 21 虚拟线程异步删除已处理的临时 Key

3.2.4 每日补偿任务

文件:SyncThumb2DBCompensatoryJob.java

@Scheduled(cron = "0 0 2 * * *")  // 每天凌晨 2 点
public void run() {
    // 扫描所有 temp_thumb:* Key
    Set<String> thumbKeys = redisTemplate.keys(RedisKeyUtil.getTempThumbKey("") + "*");
    // 对每个残留 Key 重新执行同步
    for (String date : needHandleDataSet) {
        syncThumb2DBJob.syncThumb2DBByDate(date);
    }
}

3.3 方案选型依据

维度 说明
为什么用 Lua 而非 Redis 事务 MULTI/EXEC 不支持条件判断(如 HEXISTS 检查),Lua 脚本可以在 Redis 服务端完成「检查+写入」的原子操作
为什么 10 秒分桶 平衡实时性与批量效率——太短则桶过多、同步频繁;太长则用户取消点赞后状态延迟过大
为什么处理上一个窗口 当前窗口仍在写入中,读取会丢失数据;上一个窗口已关闭,数据完整
为什么需要补偿任务 常规定时任务可能因进程重启、异常等原因遗漏某些桶;每日补偿作为最终兜底

3.4 现存漏洞

漏洞 说明
Lua 脚本 value 语义问题 取消点赞时 value 为 -1,但 SyncThumb2DBJob 中 thumbType == 0 时跳过(ThumbTypeEnum.NON),若同一用户在同一桶内先赞后取消,value 变为 0,该操作会丢失
批量删除未使用索引 LambdaQueryWrapper 构建的 OR 条件在数据量大时可能导致全表扫描
补偿任务使用 KEYS 命令 redisTemplate.keys() 在生产环境会阻塞 Redis(O(N) 扫描),应改用 SCAN
异步删除无重试 Thread.startVirtualThread() 删除临时 Key 失败后无重试机制,残留 Key 依赖补偿任务清理
事务范围过大 SyncThumb2DBJob.run() 上的 @Transactional 包裹了整个方法,若批量数据量大,事务持锁时间过长

3.5 完善方向

  • 修复 value 语义:将 Lua 脚本的 value 改为独立的点赞/取消标记(如 INCR=1 / DECR=-1 分开存储),或在同步时处理 value=0 的情况
  • KEYS → SCAN:补偿任务改用 SCAN 命令分批扫描,避免阻塞 Redis
  • 异步删除重试:引入重试机制或使用 Redis Key 的 TTL 自动过期作为兜底
  • 分页批量:SyncThumb2DBJob 应限制单次处理的数据量,避免大事务

4. HeavyKeeper Top-K 探测 vs CMS

4.1 实现状态:✅ 已实现(HeavyKeeper),❌ CMS 未实现

4.2 实现链路

文件:HeavyKeeper.java

算法核心:

输入 Key → MurmurHash3 计算指纹
         → 遍历 depth 行 Bucket
            ├── Bucket 为空 → 写入指纹和计数
            ├── 指纹匹配 → 计数递增
            └── 指纹冲突 → 按衰减概率递减现有计数
         → 若计数 ≥ minCount 且进入 Top-K → 标记为热 Key

核心参数:

参数 值 含义
k 100 追踪 Top-100 热 Key
width 100000 每行 Bucket 数量(Sketch 宽度)
depth 5 行数(哈希函数个数)
decay 0.92 衰减系数
minCount 10 最小出现次数才记录

衰减机制(两层):

  1. 写入时衰减:指纹冲突时,按 decay^count 的概率递减现有计数

    // HeavyKeeper.java:66-79
    double decay = bucket.count < LOOKUP_TABLE_SIZE ?
        lookupTable[bucket.count] :     // 预计算的 0.92^i
        lookupTable[LOOKUP_TABLE_SIZE - 1];
    if (random.nextDouble() < decay) {
        bucket.count--;
    }
    
  2. 定时衰减(fading()):每 20 秒所有计数器右移一位(减半)

    // HeavyKeeper.java:136-155
    public void fading() {
        for (Bucket[] row : buckets) {
            for (Bucket bucket : row) {
                synchronized (bucket) {
                    bucket.count = bucket.count >> 1;  // 减半
                }
            }
        }
        // minHeap 中的计数也减半
    }
    

热 Key 判定流程:

// CacheManager.java:69-74
AddResult addResult = hotKeyDetector.add(key, 1);
if (addResult.isHotKey()) {  // Key 进入 Top-100 且 count ≥ 10
    localCache.put(compositeKey, redisValue);  // 提升到本地缓存
}

4.3 HeavyKeeper vs CMS 对比分析

维度 CMS(Count-Min Sketch) HeavyKeeper
数据结构 二维计数数组 + 多个哈希函数 二维 Bucket 数组 + 指纹 + 衰减概率
冲突处理 计数累加(所有 Key 共享计数) 按衰减概率递减冷 Key 计数
冷 Key 影响 冷 Key 累加会污染热 Key 计数 冷 Key 被概率衰减淘汰,不影响热 Key
高频更新准确率 高频 Key 的计数被其他 Key 稀释 高频 Key 持续递增,低频 Key 被衰减
适用场景 频率估计(不需要精确 Top-K) Top-K 热 Key 探测(需要精确排序)
内存开销 仅存计数 存指纹 + 计数(略高)
时间适应性 无内置衰减,需手动重置 内置衰减机制,自动适应流量变化

选型理由:点赞系统的核心需求是「识别热 Key 并提升到本地缓存」,HeavyKeeper 的概率衰减机制天然适配流量变化——热点博客的 Key 持续被访问不会被衰减淘汰,而长尾 Key 会自然衰减到阈值以下。CMS 没有内置衰减机制,冷 Key 的累积计数会稀释热 Key 的准确性。

4.4 现存漏洞

漏洞 说明
total 非线程安全 total += increment(第84行)非原子操作,并发下存在竞态
minHeap 线程安全 add() 方法中先遍历 minHeap.stream() 再操作,两步之间可能被其他线程修改
衰减精度损失 fading() 使用右移(>> 1)而非浮点除法,多次衰减后计数会快速归零,短时间内的真实热 Key 可能被误淘汰
指纹碰撞 MurmurHash3 的 32 位指纹在 Key 量大时碰撞概率上升,可能导致冷 Key 被误判为热 Key
minHeap.stream() 性能 每次 add() 都遍历 minHeap 查找已有 Key(O(k)),k=100 时影响不大,但扩展性差

4.5 完善方向

  • 将 total 改为 AtomicLong
  • 对 minHeap 的查找改用 HashMap<String, Node> 辅助索引,将查找复杂度从 O(k) 降为 O(1)
  • 考虑使用 64 位指纹降低碰撞概率
  • 为 fading() 引入可配置的衰减因子(而非固定右移),避免过度衰减

5. 单机锁与分布式锁

5.1 实现状态:✅ 已实现(分布式锁)

场景 状态 说明
单机锁 ✅ 已实现(旧方案) synchronized + String.intern()(已弃用)
分布式锁 ✅ 已实现(新方案) 基于 Redis SETNX + Lua 脚本的分布式锁

注:原 synchronized + intern() 方案已替换为 Redis 分布式锁,解决了内存泄漏问题,同时支持多实例部署。

5.2 分布式锁实现链路

文件:RedisLockUtil.java

核心设计:

加锁流程:
  RedisLockUtil.tryLock(lockKey)
    → SETNX lock:thumb:{lockKey} {uuid:threadId} PX 10000
    → 成功 → 存储锁标识到 ThreadLocal
    → 失败 → 重试(50ms间隔,3秒超时)

释放流程:
  RedisLockUtil.unlock(lockKey)
    → Lua 脚本:验证锁标识 → 删除锁(原子操作)
    → 清理 ThreadLocal

加锁实现:

// RedisLockUtil.java
public boolean tryLock(String lockKey, long acquireTimeout, long lockTimeout) {
    String fullLockKey = buildLockKey(lockKey);
    String lockValue = generateLockValue();  // UUID + threadId

    while (true) {
        // 原子性 SETNX
        Boolean result = stringRedisTemplate.opsForValue()
                .setIfAbsent(fullLockKey, lockValue, lockTimeout, TimeUnit.MILLISECONDS);

        if (Boolean.TRUE.equals(result)) {
            LOCK_VALUE_HOLDER.set(lockValue);  // 存储到 ThreadLocal
            return true;
        }

        // 超时检查 + 重试
        if (System.currentTimeMillis() - startTime >= acquireTimeout) {
            return false;
        }
        Thread.sleep(50L);  // 50ms 重试间隔
    }
}

释放锁实现(Lua 脚本保证原子性):

// RedisLockUtil.java
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>("""
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """, Long.class);

public boolean unlock(String lockKey) {
    String lockValue = LOCK_VALUE_HOLDER.get();
    // Lua 脚本:验证锁标识 → 删除锁(只释放自己持有的锁)
    Long result = stringRedisTemplate.execute(UNLOCK_SCRIPT,
            Collections.singletonList(fullLockKey), lockValue);
    LOCK_VALUE_HOLDER.remove();  // 清理 ThreadLocal
    return Long.valueOf(1L).equals(result);
}

业务使用(ThumbServiceImpl):

// ThumbServiceImpl.java
public Boolean doThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) {
    // ...
    String lockKey = "USERID:" + userId;
    return redisLockUtil.executeWithLock(lockKey, () -> {
        return transactionTemplate.execute(status -> {
            // ... 检查是否已点赞 → 更新计数 → 写入 Redis + 布隆过滤器
        });
    });
}

5.3 方案选型依据

维度 说明
为什么用 Redis SETNX 而非 synchronized synchronized + intern() 存在内存泄漏,且不支持多实例部署;Redis 分布式锁天然支持跨 JVM 互斥
为什么用 Lua 脚本释放锁 避免误释放其他线程的锁——必须验证锁标识一致才能删除
为什么用 ThreadLocal 存储锁标识 确保每个线程只能释放自己持有的锁,避免并发下的误操作
为什么用 UUID + threadId 作为锁标识 UUID 保证全局唯一,threadId 便于调试和日志追踪
为什么对用户加锁而非对博客加锁 对博客加锁会导致同一博客的所有点赞请求串行,严重影响并发性能;对用户加锁只阻塞同一用户的并发操作

5.4 现存漏洞

漏洞 说明
锁续期问题 当前锁过期时间固定 10 秒,若业务执行时间超过 10 秒,锁会自动过期,可能导致并发问题
重试无退避 固定 50ms 重试间隔,在高并发场景下可能造成大量 Redis 请求(可考虑指数退避)
锁不可重入 当前实现不支持同一线程多次获取同一把锁(会死锁),但点赞场景不需要可重入
Redis 单点故障 若 Redis 宕机,分布式锁完全失效;可考虑 RedLock 算法(多 Redis 实例)提升可靠性

附录:关键文件索引

文件 职责
CacheManager.java 二级缓存管理器(Caffeine + Redis + 热 Key 提升 + 空值短缓存 + 互斥锁防击穿)
BloomFilterManager.java 布隆过滤器管理器(防缓存穿透)
HeavyKeeper.java HeavyKeeper Top-K 热 Key 探测算法
TopK.java Top-K 接口定义
Item.java Top-K 数据项 Record
RedisLockUtil.java Redis 分布式锁工具(SETNX + Lua 释放)
ThumbServiceImpl.java DB 优先方案(分布式锁 + 编程式事务 + 二级缓存 + 布隆过滤器)
ThumbServiceRedisImpl.java Redis 优先方案(Lua 脚本 + 时间片分桶)
RedisLuaScriptConstant.java Lua 脚本定义(原子点赞/取消点赞)
SyncThumb2DBJob.java 10 秒定时批量落库任务
SyncThumb2DBCompensatoryJob.java 每日补偿兜底任务
ThumbConstant.java Redis Key 前缀常量
RedisKeyUtil.java Redis Key 构建工具

总结

技术点 状态 实现方式 核心风险
布隆过滤器 ✅ 已实现 Guava BloomFilter(100万容量,1%误判率) 假阳性导致少量穿透
空值短缓存 ✅ 已实现 独立 Caffeine 实例(30秒 TTL) 空值缓存与实际数据变更的短暂不一致
互斥锁防缓存击穿 ✅ 已实现 ConcurrentHashMap + synchronized + double-check lockMap 无限膨胀
Caffeine + Redis 二级缓存 ✅ 已实现 CacheManager + HeavyKeeper 热 Key 提升 HeavyKeeper.total 非线程安全
Lua 脚本原子性 ✅ 已实现 Redis Lua 脚本 value=0 语义丢失
10 秒分桶 + 批量落库 ✅ 已实现 SyncThumb2DBJob(10秒定时) KEYS 命令阻塞 Redis
补偿兜底 ✅ 已实现 SyncThumb2DBCompensatoryJob(每日2点) 异步删除无重试
HeavyKeeper Top-K ✅ 已实现 指数衰减 + 定时 fading minHeap 查找 O(k)、指纹碰撞
分布式锁 ✅ 已实现 Redis SETNX + Lua 脚本释放 锁续期问题、Redis 单点故障