1. 淘客返利系统的缓存挑战
在典型的淘客返利系统中,每天需要处理数千万级别的商品查询请求。当用户通过返利链接跳转到电商平台时,系统需要实时查询商品信息、计算返利比例并生成追踪代码。这种场景下,数据库直接承受的QPS可能高达5万+,单纯依靠MySQL等关系型数据库根本无法支撑。
我去年参与优化的一个头部淘客平台,在双11大促期间就遭遇了严重的性能瓶颈。当时数据库CPU持续保持在95%以上,平均响应时间超过2秒,导致大量用户流失。事后分析发现,80%的查询都集中在20%的热门商品上,这就是典型的热点数据问题。
2. Redis作为一级缓存的实践
2.1 基础缓存方案设计
我们采用Redis作为一级缓存,所有查询请求首先访问Redis。Java应用通过Jedis客户端与Redis集群交互,基础实现代码如下:
public ProductInfo getProductInfo(String productId) { // 先查Redis String redisKey = "product:" + productId; String productJson = jedis.get(redisKey); if (productJson != null) { return JSON.parseObject(productJson, ProductInfo.class); } // Redis没有则查数据库 ProductInfo product = productDao.getById(productId); if (product != null) { // 写入Redis并设置过期时间 jedis.setex(redisKey, 3600, JSON.toJSONString(product)); } return product; }这里有几个关键点需要注意:
- 缓存键设计采用"业务前缀:ID"的格式,避免不同业务间的key冲突
- 设置合理的过期时间(如1小时),防止冷数据长期占用内存
- 使用JSON序列化对象,便于调试和跨语言兼容
2.2 缓存雪崩预防
当大量缓存同时过期时,会导致请求直接打到数据库,这就是缓存雪崩。我们采用两种策略应对:
- 基础过期时间加上随机抖动:
int expireTime = 3600 + new Random().nextInt(600); // 1小时±10分钟 jedis.setex(redisKey, expireTime, productJson);- 使用互斥锁防止缓存重建时的并发问题:
public ProductInfo getProductInfoWithLock(String productId) { // 先查Redis... if (productJson == null) { String lockKey = "lock:" + productId; try { // 获取分布式锁 if (jedis.setnx(lockKey, "1") == 1) { jedis.expire(lockKey, 10); // 锁10秒 // 查数据库并重建缓存... } else { // 未获取到锁,短暂等待后重试 Thread.sleep(100); return getProductInfoWithLock(productId); } } finally { jedis.del(lockKey); } } // ... }3. 本地缓存作为二级缓存的优化
3.1 为什么需要二级缓存
即使Redis集群性能很高,网络IO仍然是瓶颈。我们实测发现,在高并发场景下,单次Redis访问需要1-3ms,而本地内存访问仅需100ns左右。当QPS超过1万时,这些网络开销会显著影响系统吞吐量。
我们选用Caffeine作为本地缓存实现,它是Google Guava Cache的增强版,具有更好的并发性能。集成代码如下:
// 初始化缓存 LoadingCache<String, ProductInfo> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .refreshAfterWrite(1, TimeUnit.MINUTES) .build(productId -> { // 当缓存失效时,自动调用此方法加载数据 return getFromRedis(productId); }); public ProductInfo getProductWithLocalCache(String productId) { try { return localCache.get(productId); } catch (Exception e) { log.error("Local cache error", e); return getFromRedis(productId); } }3.2 缓存一致性处理
二级缓存带来了数据一致性问题。我们采用以下策略保证一致性:
- 数据库更新时主动清除缓存:
@Transactional public void updateProduct(ProductInfo product) { // 更新数据库 productDao.update(product); // 清除Redis缓存 jedis.del("product:" + product.getId()); // 发送MQ消息通知各节点清除本地缓存 mqProducer.send(new CacheEvictMessage(product.getId())); }本地缓存设置较短的过期时间(如5分钟),作为最终一致性保障
对于特别敏感的数据,可以使用Redisson的分布式Topic实现实时通知:
// 订阅端 RTopic topic = redisson.getTopic("cacheEvict"); topic.addListener(String.class, (channel, productId) -> { localCache.invalidate(productId); }); // 发布端 RTopic topic = redisson.getTopic("cacheEvict"); topic.publish(productId);4. 热点Key探测与动态优化
4.1 热点识别方案
我们开发了一个轻量级的热点探测模块,主要原理是:
- 在Redis客户端拦截所有请求,统计每个Key的访问频率
- 使用滑动窗口算法(时间窗口设为10秒)计算实时QPS
- 当某个Key的QPS超过阈值(如1000)时,将其标记为热点
实现代码片段:
public class HotKeyDetector { private ConcurrentHashMap<String, AtomicLong> counterMap = new ConcurrentHashMap<>(); private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); public void start() { executor.scheduleAtFixedRate(this::resetCounters, 10, 10, TimeUnit.SECONDS); } public void recordAccess(String key) { counterMap.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet(); } private void resetCounters() { counterMap.forEach((key, counter) -> { long qps = counter.getAndSet(0) / 10; if (qps > 1000) { notifyHotKey(key, qps); } }); } }4.2 热点数据处理策略
对于识别出的热点Key,我们采取以下优化措施:
- 本地缓存优先:在应用层对该Key进行特殊处理,优先从本地缓存读取
public ProductInfo getProductInfo(String productId) { if (hotKeyManager.isHotKey(productId)) { ProductInfo product = localCache.getIfPresent(productId); if (product != null) { return product; } } // 正常流程... }- Redis多副本:在Redis集群中对该Key进行多副本存储,使用一致性哈希将请求分散到不同节点
public String getHotKey(String key) { if (isHotKey(key)) { int replica = ThreadLocalRandom.current().nextInt(3); return jedis.get(key + ":" + replica); } return jedis.get(key); }- 数据预加载:提前将热点数据加载到所有应用节点的本地缓存
public void handleHotKey(String key) { // 从Redis获取最新数据 String data = jedis.get(key); // 广播到所有节点预加载 clusterManager.broadcast(() -> { localCache.put(key, data); }); }5. 性能对比与调优经验
5.1 不同方案的性能数据
我们在压测环境对比了不同方案的性能(单节点QPS):
| 方案 | 平均响应时间 | 最大QPS | 数据库负载 |
|---|---|---|---|
| 无缓存 | 120ms | 800 | 100% |
| 仅Redis缓存 | 8ms | 12,000 | 15% |
| Redis+本地缓存 | 3ms | 35,000 | 5% |
| 带热点处理的完整方案 | 2ms | 50,000+ | <1% |
5.2 实战中的经验教训
- 缓存穿透防护:对于不存在的商品ID,也要缓存空结果,防止恶意攻击
if (product == null) { jedis.setex(redisKey, 300, "NULL"); // 缓存5分钟的空值 }- 内存控制:本地缓存必须设置大小限制,我们使用LRU策略:
Caffeine.newBuilder() .maximumSize(10_000) .maximumWeight(100_000_000) // 约100MB内存 .weigher((String key, ProductInfo product) -> product.getSizeInBytes()) // ...- 监控告警:建立完善的监控体系:
- Redis内存使用率
- 缓存命中率(区分本地和Redis)
- 热点Key数量及处理状态
- 本地缓存淘汰率
- 压测技巧:在双11前进行全链路压测时,我们发现当QPS超过3万时,Redis连接池成为瓶颈。通过以下优化解决了问题:
JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(500); // 默认是8,完全不够用 config.setMaxIdle(100); config.setMinIdle(20);这套缓存架构最终帮助我们平稳度过了去年双11的流量洪峰,全天处理了超过8亿次商品查询请求,峰值QPS达到12万,而数据库负载始终保持在20%以下。最关键的是,通过热点Key的动态发现和处理机制,系统能够自动应对突发的流量变化,不再需要人工干预和紧急扩容。