飞书接口三级缓存防御方案

针对飞书接口调用 场景,缓存雪崩是"一天长周期运行"中最致命的隐患。如果缓存同时失效,所有请求瞬间穿透到飞书,不仅会打爆你的连接池,还会触发飞书严厉的限流(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();
    }
}

总结:三级防御执行顺序

  1. 请求到达 → 查缓存(Caffeine L1,最快)

  2. L1未命中 → 查Redis(L2,分布式共享)

  3. L2未命中互斥锁(防止击穿)

  4. 互斥锁竞争失败 → 等待/重试

  5. 互斥锁竞争成功 → 走RestTemplate连接池查飞书

  6. 查询结果为空空值缓存(防止穿透)

  7. 飞书超时/异常熔断降级(返回旧缓存)