针对飞书接口调用 场景,缓存雪崩是"一天长周期运行"中最致命的隐患。如果缓存同时失效,所有请求瞬间穿透到飞书,不仅会打爆你的连接池,还会触发飞书严厉的限流(429),导致业务雪崩。
我结合RestTemplate连接池 和飞书Token/数据缓存 ,给你一套三级防御方案,从源头到兜底层层拦截。
一级防御:过期时间打散(最基础)
问题:所有缓存设置相同的过期时间(如1小时),整点时刻同时失效。
方案 :给过期时间增加随机偏移量。
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
// 核心:过期时间 = 基础时间 + 随机偏移(5分钟±随机分钟)
.expireAfterWrite(Duration.ofMinutes(5 + ThreadLocalRandom.current().nextInt(10)))
// 限制缓存条目数,防止内存溢出
.maximumSize(10000)
// 开启统计
.recordStats()
);
return cacheManager;
}
}
针对飞书Token的特殊处理:
// Token过期时间不能完全随机,因为飞书返回的expire是固定值
// 但我们可以提前刷新,把"同时失效"变成"逐步刷新"
public class TokenCacheService {
// 不直接使用飞书返回的expire,而是提前5分钟刷新
private static final long REFRESH_ADVANCE = 5 * 60; // 提前5秒
public String getToken() {
String cached = cache.get("tenant_token");
if (cached == null) {
// 加锁防止缓存击穿(见二级防御)
synchronized (this) {
cached = cache.get("tenant_token");
if (cached == null) {
cached = fetchFromFeishu();
// 设置过期时间 = 飞书返回的expire - 提前量 - 随机偏移
long expireTime = feishuExpire - REFRESH_ADVANCE
- ThreadLocalRandom.current().nextInt(60); // 再减0-60秒
cache.put("tenant_token", cached, expireTime);
}
}
}
return cached;
}
}
二级防御:缓存击穿(热点Key加锁)
问题:即使过期时间打散了,但"tenant_access_token"这个超级热点Key,在它过期的那一刻,如果有100个线程同时请求,会同时穿透到飞书,瞬间产生100个Token申请请求。
方案 :互斥锁(Mutex) + 双重检查锁(DCL)。
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
@Service
public class FeishuTokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private final String TOKEN_KEY = "feishu:token";
private final String LOCK_KEY = "feishu:token:lock";
public String getToken() {
// 1. 先查缓存
String token = redisTemplate.opsForValue().get(TOKEN_KEY);
if (token != null) {
return token;
}
// 2. 缓存失效,尝试获取分布式锁(防止多个线程同时去飞书拿Token)
// 使用SET NX EX原子操作获取锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(LOCK_KEY, "locked", Duration.ofSeconds(3));
if (Boolean.TRUE.equals(locked)) {
try {
// 3. 双重检查:拿到锁后再次检查缓存(可能其他线程已经刷新了)
token = redisTemplate.opsForValue().get(TOKEN_KEY);
if (token != null) {
return token;
}
// 4. 真正去飞书获取Token(只有1个线程执行)
token = fetchTokenFromFeishu();
// 5. 写入缓存(设置过期时间为飞书返回的expire提前5分钟)
long ttl = calculateTTL(token);
redisTemplate.opsForValue().set(TOKEN_KEY, token, ttl, TimeUnit.SECONDS);
return token;
} finally {
// 6. 释放锁
redisTemplate.delete(LOCK_KEY);
}
} else {
// 7. 没拿到锁的线程,等待并重试(自旋等待)
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 递归重试
return getToken();
}
}
}
如果你是单机应用 ,直接用 synchronized 更轻量:
private final Object lock = new Object();
public String getToken() {
String token = localCache.get(TOKEN_KEY);
if (token != null) return token;
synchronized (lock) {
token = localCache.get(TOKEN_KEY);
if (token == null) {
token = fetchFromFeishu();
localCache.put(TOKEN_KEY, token, ttl);
}
}
return token;
}
三级防御:缓存穿透(空值缓存 + Bloom Filter)
问题 :如果有恶意请求查询一个不存在的用户ID,每次都会穿透缓存打到飞书,浪费连接池资源。
方案:
方案A:空值缓存(简单有效)
public UserInfo getUser(String userId) {
String cacheKey = "user:" + userId;
UserInfo user = redisTemplate.opsForValue().get(cacheKey);
// 关键:如果缓存中存储的是占位对象,说明用户不存在
if (user == null) {
// 从飞书查询
try {
user = feishuApi.getUser(userId);
if (user != null) {
// 正常缓存,过期时间1小时
redisTemplate.opsForValue().set(cacheKey, user, 1, TimeUnit.HOURS);
} else {
// 【核心】空值也缓存,但过期时间很短(5分钟)
// 防止恶意ID频繁穿透
redisTemplate.opsForValue().set(cacheKey, new NullUser(), 5, TimeUnit.MINUTES);
}
} catch (FeishuNotFoundException e) {
// 飞书返回404也缓存空值
redisTemplate.opsForValue().set(cacheKey, new NullUser(), 5, TimeUnit.MINUTES);
}
}
// 如果是空值占位对象,返回null
return user instanceof NullUser ? null : user;
}
方案B:布隆过滤器(适合超大规模)
@Component
public class BloomFilterInitializer {
// 提前从数据库/飞书全量同步所有合法用户ID到布隆过滤器
private final BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
1000000, // 预期插入1百万用户
0.01 // 误判率1%
);
@PostConstruct
public void init() {
// 从飞书全量拉取所有用户ID(一天一次即可)
List<String> allUserIds = feishuApi.getAllUserIds();
allUserIds.forEach(bloomFilter::put);
}
public UserInfo getUser(String userId) {
// 布隆过滤器判断:如果不存在,直接返回null,连缓存都不查
if (!bloomFilter.mightContain(userId)) {
return null; // 绝对不存在
}
// 可能存在,走缓存/飞书查询
return queryFromCacheOrFeishu(userId);
}
}
终极兜底:熔断降级(防止雪崩彻底击垮系统)
当飞书接口超时或异常率过高时,直接返回旧缓存数据,不让请求打到飞书。
@Service
public class FeishuApiWithFallback {
@Autowired
private RestTemplate restTemplate;
// 使用Hystrix或Resilience4j实现熔断
@CircuitBreaker(name = "feishuApi", fallbackMethod = "fallbackGetUser")
public UserInfo getUser(String userId) {
return restTemplate.getForObject("https://open.feishu.cn/...", UserInfo.class);
}
// 降级方法:返回缓存的旧数据(即使过期了,也比报错强)
public UserInfo fallbackGetUser(String userId, Throwable t) {
log.warn("飞书接口熔断,返回缓存数据,userId: {}", userId);
// 从Redis获取哪怕已过期的数据
return redisTemplate.opsForValue().get("user:old:" + userId);
}
}
针对"一天长周期"的额外建议
| 防御层次 | 实现手段 | 优先级 |
|---|---|---|
| 预防 | 过期时间打散(±随机偏移) | ⭐⭐⭐⭐⭐ 必做 |
| 预防 | 热点Key互斥锁(Token、高并发数据) | ⭐⭐⭐⭐⭐ 必做 |
| 预防 | 空值缓存(防止恶意穿透) | ⭐⭐⭐⭐ 推荐 |
| 检测 | 监控缓存命中率(低于80%告警) | ⭐⭐⭐⭐ 推荐 |
| 兜底 | 熔断降级(返回旧缓存) | ⭐⭐⭐ 可选 |
完整配置示例(结合RestTemplate)
@Configuration
public class CacheAndPoolConfig {
// 1. 连接池配置(之前提到的)
@Bean
public RestTemplate restTemplate() {
return new RestTemplate(pooledClientHttpRequestFactory());
}
// 2. 本地缓存(Caffeine)- 用于高频读取的数据
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
// 过期时间随机化(基础5分钟 ± 随机2分钟)
.expireAfterWrite(Duration.ofMinutes(5 + ThreadLocalRandom.current().nextInt(4)))
.maximumSize(10000)
.recordStats()
.build();
}
// 3. Redis分布式缓存(用于Token、部门架构等全局数据)
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// 使用随机过期时间
.entryTtl(Duration.ofSeconds(3600 + ThreadLocalRandom.current().nextInt(600)))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
总结:三级防御执行顺序
-
请求到达 → 查缓存(Caffeine L1,最快)
-
L1未命中 → 查Redis(L2,分布式共享)
-
L2未命中 → 互斥锁(防止击穿)
-
互斥锁竞争失败 → 等待/重试
-
互斥锁竞争成功 → 走RestTemplate连接池查飞书
-
查询结果为空 → 空值缓存(防止穿透)
-
飞书超时/异常 → 熔断降级(返回旧缓存)