高并发淘客系统Redis与本地缓存优化实践
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的增强版具有更好的并发性能。集成代码如下// 初始化缓存 LoadingCacheString, 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 ConcurrentHashMapString, 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数据库负载无缓存120ms800100%仅Redis缓存8ms12,00015%Redis本地缓存3ms35,0005%带热点处理的完整方案2ms50,0001%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的动态发现和处理机制系统能够自动应对突发的流量变化不再需要人工干预和紧急扩容。