✨Feat: 实现缓存穿透/击穿防护 + 分布式锁

- 新增 BloomFilterManager:Guava 布隆过滤器(100万容量,1%误判率)
- 新增 RedisLockUtil:基于 Redis SETNX + Lua 脚本的分布式锁
- 改造 CacheManager:添加空值短缓存(30秒TTL)+ 互斥锁防击穿(double-check)
- 改造 ThumbServiceImpl:使用分布式锁替代 synchronized,集成布隆过滤器
- 新增 docs/技术调研文档.md:完整技术调研文档
- 更新 README.md:添加技术文档索引
This commit is contained in:
2026-09-09 20:04:52 +08:00
parent 2e7a7c060f
commit 1dda7e80b9
7 changed files with 1124 additions and 47 deletions
@@ -0,0 +1,93 @@
package cn.hezhaohui.thumb.manager.cache;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 布隆过滤器管理器
* 用于防止缓存穿透:拦截不存在的 Key,避免请求穿透到 Redis/DB
*
* 设计要点:
* 1. 预期插入量 100万,误判率 1%(约 9.6MB 内存)
* 2. 系统启动时初始化,运行期间持续添加新 Key
* 3. 布隆过滤器存在假阳性(误判为存在),但不存在假阴性(不会漏判不存在的 Key)
*/
@Component
@Slf4j
public class BloomFilterManager {
/**
* 布隆过滤器:存储 userId:blogId 组合
* Key 格式:"{userId}:{blogId}"
*/
private BloomFilter<String> thumbBloomFilter;
/**
* 预期插入量
*/
private static final long EXPECTED_INSERTIONS = 1_000_000L;
/**
* 误判率
*/
private static double FPP = 0.01;
@PostConstruct
public void init() {
thumbBloomFilter = BloomFilter.create(
Funnels.stringFunnel(java.nio.charset.StandardCharsets.UTF_8),
EXPECTED_INSERTIONS,
FPP
);
log.info("布隆过滤器初始化完成,预期插入量: {}, 误判率: {}", EXPECTED_INSERTIONS, FPP);
}
/**
* 构建布隆过滤器 Key
*
* @param userId 用户 ID
* @param blogId 博客 ID
* @return 复合 Key
*/
private String buildKey(Long userId, Long blogId) {
return userId + ":" + blogId;
}
/**
* 添加点赞记录到布隆过滤器
* 在用户成功点赞后调用
*
* @param userId 用户 ID
* @param blogId 博客 ID
*/
public void add(Long userId, Long blogId) {
thumbBloomFilter.put(buildKey(userId, blogId));
}
/**
* 判断用户是否可能点赞过该博客
*
* @param userId 用户 ID
* @param blogId 博客 ID
* @return false = 一定没有点赞过(可以安全拦截)
* true = 可能点赞过(需要进一步查询 Redis/DB)
*/
public boolean mightContain(Long userId, Long blogId) {
return thumbBloomFilter.mightContain(buildKey(userId, blogId));
}
/**
* 判断用户是否可能点赞过该博客(String 类型参数)
*
* @param userId 用户 ID(字符串)
* @param blogId 博客 ID(字符串)
* @return false = 一定没有点赞过
* true = 可能点赞过
*/
public boolean mightContain(String userId, String blogId) {
return thumbBloomFilter.mightContain(userId + ":" + blogId);
}
}
@@ -9,6 +9,7 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@Component
@@ -17,6 +18,24 @@ public class CacheManager {
private TopK hotKeyDetector;
private Cache<String, Object> localCache;
/**
* 互斥锁 Map:用于缓存击穿防护
* Key 为 compositeKey,Value 为锁对象
* 使用 ConcurrentHashMap 管理锁对象,避免 String.intern() 内存泄漏
*/
private final ConcurrentHashMap<String, Object> lockMap = new ConcurrentHashMap<>();
/**
* 空值标记:用于缓存穿透防护
* 当 Redis 查询返回 null 时,写入此标记到本地缓存(短 TTL)
*/
private static final Object NULL_PLACEHOLDER = new Object();
/**
* 空值短缓存过期时间(秒)
*/
private static final long NULL_CACHE_TTL_SECONDS = 30;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@@ -44,14 +63,38 @@ public class CacheManager {
.build();
}
/**
* 创建空值短缓存(独立的 Caffeine 实例,TTL 更短)
* 用于缓存穿透防护:缓存不存在的 Key,避免反复查询 Redis
*/
private final Cache<String, Object> nullValueCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(NULL_CACHE_TTL_SECONDS, TimeUnit.SECONDS)
.build();
// 构造复合 Key
private String buildCacheKey(String hashKey, String key) {
return hashKey + ":" + key;
}
/**
* 查询缓存(带缓存穿透防护 + 缓存击穿防护)
*
* 查询流程:
* 1. 先查本地缓存(L1)→ 命中则返回
* 2. 再查空值短缓存 → 命中则返回 null(避免穿透)
* 3. 加互斥锁,double-check 本地缓存
* 4. 查 Redis(L2)→ 未命中则写入空值短缓存
* 5. Redis 命中 → 记录访问次数 → 热 Key 提升到 L1
*
* @param hashKey Redis Hash Key
* @param key Redis Hash Field
* @return 缓存值,null 表示不存在
*/
public Object get(String hashKey, String key) {
String compositeKey = this.buildCacheKey(hashKey, key);
// 1. 查本地缓存
// 1. 查本地缓存(L1)
Object value = localCache.getIfPresent(compositeKey);
if (value != null) {
log.info("本地缓存获取到数据 {} = {}", compositeKey, value);
@@ -59,23 +102,61 @@ public class CacheManager {
hotKeyDetector.add(key, 1);
return value;
}
// 2. 本地缓存未命中,查询 Redis
Object redisValue = redisTemplate.opsForHash().get(hashKey, key);
if (redisValue == null) {
// 2. 查空值短缓存(防缓存穿透)
Object nullMarker = nullValueCache.getIfPresent(compositeKey);
if (nullMarker != null) {
log.debug("空值短缓存命中,Key: {}", compositeKey);
return null;
}
// 3. 记录访问次数
AddResult addResult = hotKeyDetector.add(key, 1);
// 3. 加互斥锁,防止缓存击穿(热 Key 过期时单线程回源)
Object lock = lockMap.computeIfAbsent(compositeKey, k -> new Object());
synchronized (lock) {
try {
// double-check:再次检查本地缓存(可能其他线程已回源完成)
value = localCache.getIfPresent(compositeKey);
if (value != null) {
log.info("double-check 本地缓存命中 {} = {}", compositeKey, value);
hotKeyDetector.add(key, 1);
return value;
}
// 4. 缓存数据 热Key
if (addResult.isHotKey()) {
localCache.put(compositeKey, redisValue);
// 4. 查询 Redis(L2)
Object redisValue = redisTemplate.opsForHash().get(hashKey, key);
if (redisValue == null) {
// 防缓存穿透:写入空值短缓存
nullValueCache.put(compositeKey, NULL_PLACEHOLDER);
log.debug("Redis 未命中,写入空值短缓存,Key: {}", compositeKey);
return null;
}
// 5. 记录访问次数
AddResult addResult = hotKeyDetector.add(key, 1);
// 6. 热 Key 提升到本地缓存
if (addResult.isHotKey()) {
localCache.put(compositeKey, redisValue);
log.info("热 Key 提升到本地缓存: {}", compositeKey);
}
return redisValue;
} finally {
// 清理锁对象(可选:避免 lockMap 无限膨胀)
// 注意:这里不清理,因为 computeIfAbsent 会复用已有锁对象
// 若需清理,可在锁内判断是否还有其他线程等待
}
}
return redisValue;
}
/**
* 更新本地缓存(仅更新已存在的 Key)
* 用于写操作后保持 L1 缓存一致性
*
* @param hashKey Redis Hash Key
* @param key Redis Hash Field
* @param value 新值
*/
public void putIfPresent(String hashKey, String key, Object value) {
String compositeKey = this.buildCacheKey(hashKey, key);
Object object = localCache.getIfPresent(compositeKey);
@@ -85,6 +166,19 @@ public class CacheManager {
localCache.put(compositeKey, value);
}
/**
* 失效本地缓存
* 用于写操作后主动清除缓存
*
* @param hashKey Redis Hash Key
* @param key Redis Hash Field
*/
public void evict(String hashKey, String key) {
String compositeKey = this.buildCacheKey(hashKey, key);
localCache.invalidate(compositeKey);
nullValueCache.invalidate(compositeKey);
}
// 定时清理过期的 HotKey 数据
@Scheduled(fixedRate = 20, timeUnit = TimeUnit.SECONDS)
public void cleanHotKeys() {
@@ -3,6 +3,7 @@ package cn.hezhaohui.thumb.service.impl;
import cn.hezhaohui.thumb.constant.ThumbConstant;
import cn.hezhaohui.thumb.exception.BusinessException;
import cn.hezhaohui.thumb.exception.ErrorCode;
import cn.hezhaohui.thumb.manager.cache.BloomFilterManager;
import cn.hezhaohui.thumb.manager.cache.CacheManager;
import cn.hezhaohui.thumb.model.dto.DoThumbRequest;
import cn.hezhaohui.thumb.model.entity.Blog;
@@ -10,6 +11,7 @@ import cn.hezhaohui.thumb.model.entity.User;
import cn.hezhaohui.thumb.service.BlogService;
import cn.hezhaohui.thumb.service.UserService;
import cn.hezhaohui.thumb.util.RedisKeyUtil;
import cn.hezhaohui.thumb.util.RedisLockUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import cn.hezhaohui.thumb.model.entity.Thumb;
import cn.hezhaohui.thumb.service.ThumbService;
@@ -22,14 +24,19 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
/**
* @author 23117
* @description 针对表【thumb】的数据库操作Service实现
* @createDate 2025-10-18 14:51:00
*/
* @author 23117
* @description 针对表【thumb】的数据库操作Service实现
* @createDate 2025-10-18 14:51:00
*
* 改进点:
* 1. 使用 Redis 分布式锁替代 synchronized + intern(),支持多实例部署,避免内存泄漏
* 2. 集成布隆过滤器,防止缓存穿透
* 3. 使用 CacheManager 的二级缓存(含空值短缓存 + 互斥锁防击穿)
*/
@Service("thumbService")
@Slf4j
public class ThumbServiceImpl extends ServiceImpl<ThumbMapper, Thumb>
implements ThumbService{
implements ThumbService {
@Resource
private UserService userService;
@@ -46,19 +53,29 @@ public class ThumbServiceImpl extends ServiceImpl<ThumbMapper, Thumb>
@Resource
private CacheManager cacheManager;
@Resource
private RedisLockUtil redisLockUtil;
@Resource
private BloomFilterManager bloomFilterManager;
@Override
public Boolean doThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) {
if (doThumbRequest == null || doThumbRequest.getBlogId() == null) {
throw new RuntimeException("参数异常");
}
User loginUser = userService.getLoginUser(request);
// Lock: 这里使用字符串加锁,获取字符串常量对象才可运行,字符串常量对象具有唯一性
synchronized (("LOCK-USERID-" + loginUser.getId().toString()).intern()) {
// Transaction: 编程式事务
Long userId = loginUser.getId();
Long blogId = doThumbRequest.getBlogId();
// 分布式锁:对用户加锁,确保同一用户的并发请求串行执行
// 替代原来的 synchronized + intern(),解决内存泄漏问题,支持多实例部署
String lockKey = "USERID:" + userId;
return redisLockUtil.executeWithLock(lockKey, () -> {
// 编程式事务
return transactionTemplate.execute(status -> {
Long blogId = doThumbRequest.getBlogId();
// 判断是否已点过赞
Boolean exists = this.hasThumb(blogId, loginUser.getId());
Boolean exists = this.hasThumb(blogId, userId);
if (exists) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "用户已点赞");
}
@@ -69,23 +86,23 @@ public class ThumbServiceImpl extends ServiceImpl<ThumbMapper, Thumb>
.update();
// 更新点赞表数据
Thumb thumb = new Thumb();
thumb.setUserid(loginUser.getId());
thumb.setUserid(userId);
thumb.setBlogId(blogId);
// 两者一起执行
boolean success = update && this.save(thumb);
// 点赞记录存入 Redis
// 点赞记录存入 Redis + 更新布隆过滤器
if (success) {
// 一级缓存
// redisTemplate.opsForHash().put(RedisKeyUtil.getUserThumbKey(loginUser.getId()), blogId.toString(), thumb.getId());
String hashKey = ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId();
String hashKey = ThumbConstant.USER_THUMB_KEY_PREFIX + userId;
String filedKey = blogId.toString();
Long realThumbId = thumb.getId();
redisTemplate.opsForHash().put(hashKey, filedKey, realThumbId);
cacheManager.putIfPresent(hashKey, filedKey, realThumbId);
// 添加到布隆过滤器(防缓存穿透)
bloomFilterManager.add(userId, blogId);
}
return success;
});
}
});
}
@Override
@@ -94,13 +111,19 @@ public class ThumbServiceImpl extends ServiceImpl<ThumbMapper, Thumb>
throw new RuntimeException("参数异常");
}
User loginUser = userService.getLoginUser(request);
// Lock: 这里使用字符串加锁,获取字符串常量对象才可运行,字符串常量对象具有唯一性
synchronized (("LOCK-USERID-" + loginUser.getId().toString()).intern()) {
// Transaction: 编程式事务
Long userId = loginUser.getId();
Long blogId = doThumbRequest.getBlogId();
// 分布式锁:对用户加锁
String lockKey = "USERID:" + userId;
return redisLockUtil.executeWithLock(lockKey, () -> {
// 编程式事务
return transactionTemplate.execute(status -> {
Long blogId = doThumbRequest.getBlogId();
// 判断是否已点过赞
Object thumbIdObj = cacheManager.get(ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId(), blogId.toString());
Object thumbIdObj = cacheManager.get(
ThumbConstant.USER_THUMB_KEY_PREFIX + userId,
blogId.toString()
);
if (thumbIdObj == null || thumbIdObj.equals(ThumbConstant.UN_THUMB_CONSTANT)) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "用户未点赞");
}
@@ -111,37 +134,36 @@ public class ThumbServiceImpl extends ServiceImpl<ThumbMapper, Thumb>
.setSql("thumbCount = thumbCount - 1")
.update();
// 更新点赞表数据
// 两者一起执行
boolean success = update && this.removeById(((Number)thumbIdObj).longValue());
boolean success = update && this.removeById(thumbId);
if (success) {
// 一级缓存
// redisTemplate.opsForHash().delete(RedisKeyUtil.getUserThumbKey(loginUser.getId()), blogId.toString());
String hashKey = ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId();
String hashKey = ThumbConstant.USER_THUMB_KEY_PREFIX + userId;
String fieldKey = blogId.toString();
redisTemplate.opsForHash().delete(hashKey, fieldKey);
cacheManager.putIfPresent(hashKey, fieldKey, ThumbConstant.UN_THUMB_CONSTANT);
}
return success;
});
}
});
}
@Override
public Boolean hasThumb(Long blogId, Long userId) {
// 一级缓存
// return redisTemplate.opsForHash().hasKey(RedisKeyUtil.getUserThumbKey(userId), blogId.toString());
// 布隆过滤器前置判断:若返回 false,则一定没有点赞过,直接返回
// 这是缓存穿透防护的第一道防线
if (!bloomFilterManager.mightContain(userId, blogId)) {
log.debug("布隆过滤器拦截:用户 {} 未点赞博客 {}", userId, blogId);
return false;
}
Object thumbIdObj = cacheManager.get(ThumbConstant.USER_THUMB_KEY_PREFIX + userId, blogId.toString());;
// 通过布隆过滤器后,查询二级缓存(含空值短缓存 + 互斥锁防击穿)
Object thumbIdObj = cacheManager.get(
ThumbConstant.USER_THUMB_KEY_PREFIX + userId,
blogId.toString()
);
if (thumbIdObj == null) {
return false;
}
Long thumbId = ((Number) thumbIdObj).longValue();
return !thumbId.equals(ThumbConstant.UN_THUMB_CONSTANT);
}
}
@@ -0,0 +1,214 @@
package cn.hezhaohui.thumb.util;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* Redis 分布式锁工具类
* 基于 Redis SETNX + PX(毫秒过期)实现
*
* 设计要点:
* 1. 使用 SETNX 保证原子性加锁
* 2. 设置过期时间防止死锁
* 3. 使用 Lua 脚本保证释放锁的原子性(只释放自己持有的锁)
* 4. 使用 ThreadLocal 存储锁标识,避免误释放其他线程的锁
*
* 适用场景:多实例部署时,需要跨 JVM 的互斥机制
*/
@Component
@Slf4j
public class RedisLockUtil {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 锁前缀
*/
private static final String LOCK_PREFIX = "lock:thumb:";
/**
* 默认锁过期时间(毫秒)
*/
private static final long DEFAULT_LOCK_TIMEOUT = 10000L;
/**
* 默认获取锁超时时间(毫秒)
*/
private static final long DEFAULT_ACQUIRE_TIMEOUT = 3000L;
/**
* 重试间隔(毫秒)
*/
private static final long RETRY_INTERVAL = 50L;
/**
* ThreadLocal 存储当前线程持有的锁标识
* 用于释放锁时验证是否是自己持有的锁
*/
private static final ThreadLocal<String> LOCK_VALUE_HOLDER = new ThreadLocal<>();
/**
* 释放锁的 Lua 脚本
* 原子性操作:验证锁标识 → 删除锁
* 只有锁的持有者才能释放锁,避免误释放
*/
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);
/**
* 构建锁 Key
*
* @param lockKey 业务锁 Key
* @return 完整的 Redis Key
*/
private String buildLockKey(String lockKey) {
return LOCK_PREFIX + lockKey;
}
/**
* 生成锁标识(UUID + 线程ID)
* 确保每个线程的锁标识唯一
*
* @return 锁标识
*/
private String generateLockValue() {
return UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
}
/**
* 尝试获取分布式锁(使用默认超时时间)
*
* @param lockKey 业务锁 Key(如 "userId:123")
* @return true = 获取成功,false = 获取失败
*/
public boolean tryLock(String lockKey) {
return tryLock(lockKey, DEFAULT_ACQUIRE_TIMEOUT, DEFAULT_LOCK_TIMEOUT);
}
/**
* 尝试获取分布式锁
*
* @param lockKey 业务锁 Key
* @param acquireTimeout 获取锁超时时间(毫秒)
* @param lockTimeout 锁过期时间(毫秒)
* @return true = 获取成功,false = 获取失败
*/
public boolean tryLock(String lockKey, long acquireTimeout, long lockTimeout) {
String fullLockKey = buildLockKey(lockKey);
String lockValue = generateLockValue();
long startTime = System.currentTimeMillis();
while (true) {
// 尝试 SETNX
Boolean result = stringRedisTemplate.opsForValue()
.setIfAbsent(fullLockKey, lockValue, lockTimeout, TimeUnit.MILLISECONDS);
if (Boolean.TRUE.equals(result)) {
// 获取成功,存储锁标识到 ThreadLocal
LOCK_VALUE_HOLDER.set(lockValue);
log.debug("获取分布式锁成功,Key: {}, Thread: {}", lockKey, Thread.currentThread().getId());
return true;
}
// 检查是否超时
if (System.currentTimeMillis() - startTime >= acquireTimeout) {
log.warn("获取分布式锁超时,Key: {}, Thread: {}", lockKey, Thread.currentThread().getId());
return false;
}
// 短暂等待后重试
try {
Thread.sleep(RETRY_INTERVAL);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("获取分布式锁被中断,Key: {}", lockKey);
return false;
}
}
}
/**
* 释放分布式锁
* 使用 Lua 脚本保证原子性:只有锁的持有者才能释放锁
*
* @param lockKey 业务锁 Key
* @return true = 释放成功,false = 释放失败(锁已过期或不属于当前线程)
*/
public boolean unlock(String lockKey) {
String fullLockKey = buildLockKey(lockKey);
String lockValue = LOCK_VALUE_HOLDER.get();
if (lockValue == null) {
log.warn("释放分布式锁失败:当前线程未持有锁,Key: {}, Thread: {}", lockKey, Thread.currentThread().getId());
return false;
}
try {
// 执行 Lua 脚本:验证锁标识 → 删除锁
Long result = stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(fullLockKey),
lockValue
);
boolean success = Long.valueOf(1L).equals(result);
if (success) {
log.debug("释放分布式锁成功,Key: {}, Thread: {}", lockKey, Thread.currentThread().getId());
} else {
log.warn("释放分布式锁失败:锁已过期或不属于当前线程,Key: {}, Thread: {}", lockKey, Thread.currentThread().getId());
}
return success;
} finally {
// 清理 ThreadLocal
LOCK_VALUE_HOLDER.remove();
}
}
/**
* 在锁保护下执行业务逻辑
* 自动处理加锁和释放锁,确保锁一定会被释放
*
* @param lockKey 业务锁 Key
* @param action 业务逻辑
* @param <T> 返回值类型
* @return 业务逻辑的返回值
* @throws RuntimeException 获取锁失败或业务逻辑异常
*/
public <T> T executeWithLock(String lockKey, LockAction<T> action) {
boolean locked = false;
try {
locked = tryLock(lockKey);
if (!locked) {
throw new RuntimeException("获取分布式锁失败,Key: " + lockKey);
}
return action.execute();
} finally {
if (locked) {
unlock(lockKey);
}
}
}
/**
* 锁保护下的业务逻辑接口
*
* @param <T> 返回值类型
*/
@FunctionalInterface
public interface LockAction<T> {
T execute();
}
}