01 · 引言:缓存不是银弹,而是双刃剑
在外贸电商、支付网关、库存交易这类高并发系统里,缓存是抗住流量洪峰的第一道闸门。但生产环境中,缓存也是最容易被低估的组件------你以为加了个 Redis 就能高枕无忧,结果上线后 Hot Key 把单节点打挂、Big Key 阻塞整集群、缓存一致性让下游数据"阴晴不定"。
本文基于真实生产场景,从 Redis Cluster 架构、Redisson 分布式锁、Hot/Big Key 治理到缓存一致性,给出一套敢上量、可观测、能兜底的落地方案。代码不是 Hello World,而是带背压、熔断、上下文传播和监控埋点的生产骨架。
| 指标 | 目标 |
|---|---|
| 峰值 QPS 经缓存层削峰 | 10w+ |
| P99 读缓存延迟 | < 5ms |
| 大集群常见故障根因 | 3 类(Hot/Big/一致性) |
设计原则: 缓存层必须做"横向可扩展 + 纵向热点隔离"。不要只关注集群有多少主节点,要关注每个 key 被访问的频次和大小。
02 · Redis Cluster 架构:分片不是免死金牌
Redis Cluster 采用无中心架构,16384 个槽位分配到多个主节点,每个主节点挂 1-N 个从节点。数据按 key 的 CRC16 取模落在槽位上,客户端通过 MOVED/ASK 重定向定位节点。
text
┌──────────────────────────────────────┐
│ Redis Cluster 三主三从 │
└──────────────────────────────────────┘
Master-A (slots 0-5460) ──→ Replica-A1, Replica-A2
Master-B (slots 5461-10922) ──→ Replica-B1, Replica-B2
Master-C (slots 10923-16383) ──→ Replica-C1, Replica-C2
但分片本身无法解决以下问题:
- Hot Key:同一 key 被高频访问,所在节点 CPU/带宽被打爆,其他节点空闲。
- Big Key:单个 value 过大(如 List/Hash 百万级),导致持久化阻塞、主从同步慢。
- 倾斜:业务 key 分布不均,某些槽位流量是其他的十倍。
03 · Redisson 分布式锁:看门狗不是万能的
生产环境最忌讳"自己实现 Redis 锁"。Redisson 的 RLock 已经封装了加锁、可重入、自动续期(Watch Dog)、订阅释放通知等机制。但默认配置往往不能直接上生产。
3.1 配置:必须显式指定锁等待时间与自动续期策略
java
// 生产级 Redisson 配置:多主节点、重试、超时、连接池隔离
@Configuration
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
Config config = new Config();
config.useClusterServers()
.addNodeAddress(
"redis://10.0.1.10:7000",
"redis://10.0.1.11:7000",
"redis://10.0.1.12:7000"
)
.setScanInterval(2000) // 拓扑扫描 2s
.setMasterConnectionMinimumIdleSize(16)
.setMasterConnectionPoolSize(64)
.setSlaveConnectionMinimumIdleSize(16)
.setSlaveConnectionPoolSize(64)
.setRetryAttempts(3)
.setRetryInterval(1500)
.setConnectTimeout(3000)
.setTimeout(3000)
.setPingConnectionInterval(5000);
return Redisson.create(config);
}
}
3.2 业务锁:库存扣减 + 上下文传播 + 监控埋点
java
@Service
@RequiredArgsConstructor
public class InventoryLockService {
private final RedissonClient redisson;
private final MeterRegistry meterRegistry;
private final Tracer tracer;
private static final String LOCK_PREFIX = "lock:inventory:";
private static final long WAIT_SEC = 3;
private static final long LEASE_SEC = 10;
public boolean deductWithLock(String skuId, int qty, String traceId) {
String key = LOCK_PREFIX + skuId;
RLock lock = redisson.getLock(key);
// 1. 上下文传播:把 traceId 放进 MDC,后续链路可追踪
MDC.put("traceId", traceId);
long start = System.nanoTime();
try {
// 2. 加锁:最多等 3s,持有 10s,自动续期默认 30s/3 = 10s
boolean locked = lock.tryLock(WAIT_SEC, LEASE_SEC, TimeUnit.SECONDS);
if (!locked) {
meterRegistry.counter("redis.lock.fail", "sku", skuId).increment();
return false;
}
// 3. 业务执行:双重检查,防止锁外逻辑并发
int stock = queryStock(skuId);
if (stock < qty) {
throw new BusinessException("库存不足");
}
updateStock(skuId, stock - qty);
recordDeductionLog(skuId, qty, traceId);
meterRegistry.counter("redis.lock.success", "sku", skuId).increment();
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new SystemException("加锁被中断", e);
} finally {
// 4. 只有当前线程持有才释放,避免误删他人锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
long cost = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
meterRegistry.timer("redis.lock.duration", "sku", skuId)
.record(cost, TimeUnit.MILLISECONDS);
MDC.remove("traceId");
}
}
}
⚠️ 锁失效的常见场景
- 锁内业务执行时间超过 leaseTime,看门狗续期失败导致锁被其他线程获取。
- 网络抖动导致 unlock 没发到 Redis,锁过期后业务还在执行。
- 没有
isHeldByCurrentThread()保护,A线程释放B线程的锁。
04 · Hot Key & Big Key 治理:分层+探测+拆分
当单个 SKU 做秒杀、或者单个商户订单量爆发时,Redis 节点会出现"局部过载"。治理思路是本地缓存削峰 + 热 key 实时探测 + 大 key 拆分/压缩。
4.1 本地缓存 + 概率淘汰策略
java
@Component
public class LocalHotKeyCache {
// Caffeine:本地热点缓存,命中率 80%+,显著降低 Redis 单点压力
private final LoadingCache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(5)) // 短过期,接受弱一致
.recordStats()
.build(k -> null);
public Object get(String key) {
return localCache.getIfPresent(key);
}
public void put(String key, Object value) {
localCache.put(key, value);
}
public double hitRate() {
return localCache.stats().hitRate();
}
}
4.2 热 key 探测:基于客户端采样
java
@Component
public class HotKeyDetector {
private final ConcurrentHashMap<String, AtomicLong> hotKeys = new ConcurrentHashMap<>();
private final int sampleMask = 0xFF; // 每 256 次采样一次
public void record(String key) {
// 随机采样降低 CPU,热点 key 即使采样也能被命中
if ((ThreadLocalRandom.current().nextInt() & sampleMask) != 0) {
return;
}
AtomicLong counter = hotKeys.computeIfAbsent(key, k -> new AtomicLong(0));
long cnt = counter.incrementAndGet();
if (cnt > 1000) {
// 超过阈值,上报到本地缓存或配置中心触发本地缓存预热
AlarmUtil.send("hotkey", key, cnt);
counter.set(0);
}
}
}
4.3 Big Key 拆分:Hash 分桶,避免单 key 爆炸
java
@Service
public class ShardingHashService {
private final RedissonClient redisson;
private static final int BUCKET_COUNT = 128;
public void hSetBig(String key, String field, String value) {
int bucket = Math.abs(field.hashCode() % BUCKET_COUNT);
String realKey = key + ":" + bucket;
RMap<String, String> map = redisson.getMap(realKey);
map.fastPut(field, value);
}
public String hGetBig(String key, String field) {
int bucket = Math.abs(field.hashCode() % BUCKET_COUNT);
String realKey = key + ":" + bucket;
RMap<String, String> map = redisson.getMap(realKey);
return map.get(field);
}
// 批量读取:并发访问多个桶,聚合结果
public Map<String, String> hGetAllBig(String key) {
Map<String, String> result = new ConcurrentHashMap<>();
List<CompletableFuture<?>> futures = new ArrayList<>();
for (int i = 0; i < BUCKET_COUNT; i++) {
int bucket = i;
futures.add(CompletableFuture.runAsync(() -> {
RMap<String, String> map = redisson.getMap(key + ":" + bucket);
result.putAll(map.readAllEntrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
}));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
return result;
}
}
05 · 缓存一致性:没有完美方案,只有 trade-off
缓存与数据库的一致性,从来都不是"先删缓存还是先更新数据库"这种简单二选一。生产上更稳妥的是Cache-Aside + 延时双删 + 消息队列最终一致。
text
写请求 → 更新 DB → 删除缓存 → 发送 MQ 延时消息 → 500ms 后再次删缓存
5.1 生产级写服务:带重试、幂等、熔断
java
@Service
@RequiredArgsConstructor
public class ProductWriteService {
private final ProductMapper productMapper;
private final RedissonClient redisson;
private final KafkaTemplate<String, String> kafkaTemplate;
private final MeterRegistry meterRegistry;
private static final String CACHE_KEY = "product:info:";
@Transactional
public void updateProduct(ProductDTO dto, String traceId) {
MDC.put("traceId", traceId);
long start = System.nanoTime();
try {
// 1. 先更新数据库,保证主库为唯一真相源
productMapper.update(dto);
// 2. 删除缓存
String cacheKey = CACHE_KEY + dto.getId();
deleteCache(cacheKey);
// 3. 发送延时消息,500ms 后兜底二次删除
kafkaTemplate.send(
"cache-invalidate-delay",
cacheKey,
cacheKey
).whenComplete((res, ex) -> {
if (ex != null) {
meterRegistry.counter("cache.invalidate.mq.fail").increment();
}
});
meterRegistry.timer("product.write.duration")
.record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
} finally {
MDC.remove("traceId");
}
}
// 删缓存带重试与熔断:防止 Redis 抖动时写失败导致脏读
private void deleteCache(String key) {
int attempts = 0;
while (attempts < 3) {
try {
redisson.getBucket(key).delete();
return;
} catch (Exception e) {
attempts++;
meterRegistry.counter("cache.delete.retry").increment();
if (attempts >= 3) {
// 熔断:记录失败,交给 MQ 消费者兜底
meterRegistry.counter("cache.delete.fail").increment();
throw new CacheOpsException("缓存删除失败", e);
}
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(50));
}
}
}
}
5.2 读路径:多级缓存 + 互斥锁防击穿
java
@Service
@RequiredArgsConstructor
public class ProductReadService {
private final RedissonClient redisson;
private final ProductMapper productMapper;
private final LocalHotKeyCache localCache;
private final MeterRegistry meterRegistry;
public ProductDTO getProduct(Long id) {
String key = "product:info:" + id;
// 1. 本地缓存(Caffeine)
Object local = localCache.get(key);
if (local != null) {
meterRegistry.counter("cache.hit.local").increment();
return (ProductDTO) local;
}
// 2. Redis 缓存
RBucket<ProductDTO> bucket = redisson.getBucket(key);
ProductDTO cached = bucket.get();
if (cached != null) {
localCache.put(key, cached);
meterRegistry.counter("cache.hit.redis").increment();
return cached;
}
// 3. 互斥锁防止缓存击穿
String lockKey = "lock:product:" + id;
RLock lock = redisson.getLock(lockKey);
try {
boolean locked = lock.tryLock(2, 5, TimeUnit.SECONDS);
if (!locked) {
meterRegistry.counter("cache.load.lock.fail").increment();
return productMapper.selectById(id); // 降级直接查库
}
// 双重检查
cached = bucket.get();
if (cached != null) {
return cached;
}
ProductDTO db = productMapper.selectById(id);
if (db != null) {
bucket.set(db, Duration.ofMinutes(10));
localCache.put(key, db);
} else {
// 空值缓存,防穿透
bucket.set(ProductDTO.EMPTY, Duration.ofMinutes(2));
}
meterRegistry.counter("cache.load.db").increment();
return db;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new SystemException("读缓存中断", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
06 · 生产级骨架:并发、背压、熔断、监控四位一体
真正上量的系统,不能只在业务代码里 patch。下面是缓存服务的入口切面,整合了Semaphore 并发控制、RateLimiter 背压、CircuitBreaker 熔断、Micrometer 指标。
java
@Aspect
@Component
@RequiredArgsConstructor
public class CacheAccessAspect {
private final MeterRegistry meterRegistry;
private final RateLimiter rateLimiter = RateLimiter.create(5000.0); // 背压
private final Semaphore concurrency = new Semaphore(200); // 并发控制
private final CircuitBreaker cacheBreaker = CircuitBreaker.ofDefaults("redis");
@Around("@annotation(CacheGuard)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
String method = pjp.getSignature().getName();
// 1. 背压:超过阈值则快速失败,保护 Redis
if (!rateLimiter.tryAcquire()) {
meterRegistry.counter("cache.backpressure.reject", "method", method).increment();
throw new BackpressureException("请求过载,请稍后重试");
}
// 2. 并发控制:避免同一时刻大量线程穿透到缓存
if (!concurrency.tryAcquire(200, TimeUnit.MILLISECONDS)) {
meterRegistry.counter("cache.concurrency.reject", "method", method).increment();
throw new ConcurrencyException("并发限流");
}
try {
// 3. 熔断:Redis 连续失败时直接降级走 DB 或本地缓存
return cacheBreaker.executeSupplier(() -> {
try {
return pjp.proceed();
} catch (Throwable t) {
throw new RuntimeException(t);
}
});
} catch (CallNotPermittedException | RuntimeException ex) {
meterRegistry.counter("cache.circuitbreaker.open", "method", method).increment();
return fallback(pjp); // 降级:本地缓存或数据库
} finally {
concurrency.release();
long cost = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
meterRegistry.timer("cache.access.duration", "method", method)
.record(cost, TimeUnit.MILLISECONDS);
}
}
private Object fallback(ProceedingJoinPoint pjp) {
// 实际生产:返回空值/默认值/本地缓存,或抛自定义降级异常
return null;
}
}
07 · 监控指标:没有指标,就是瞎子摸象
Redis 上线后,必须建立以下指标看板,才能真正"敢上量"。
| 指标类别 | 关键指标 | 告警阈值建议 |
|---|---|---|
| 性能 | latency_p99、qps、connection_used | latency_p99 > 20ms 持续 1min 告警 |
| 容量 | used_memory、memory_fragmentation_ratio、key_count | 内存使用率 > 80% 告警 |
| 热点 | hot_key_top10、key_access_distribution | 单 key QPS > 节点 20% 流量 |
| 一致性 | cache_hit_rate、db_fallback_rate、invalidate_fail | 命中率 < 70% 或 fallback 激增 |
| 锁 | lock_duration、lock_fail、lock_held_timeout | 锁失败率 > 1% 或持有时长异常 |
08 · 生产踩坑:这 6 个问题我们真金白银买过
坑 1:Redisson 默认 leaseTime 太长,故障时锁不释放
修复: 显式设置 leaseTime,业务内再兜底 unlock;避免用默认 30s 锁跑长事务。
坑 2:Big Key 导致 RDB/AOF 阻塞,整集群抖动
修复: Hash 分桶、List 分片、String 压缩;定期用 redis-cli --bigkeys 扫描。
坑 3:缓存穿透被恶意请求打穿数据库
修复: 空值缓存 + BloomFilter;接口层加 WAF/限流。
坑 4:先删缓存后更新 DB,并发读导致旧缓存回填
修复: 延时双删 + MQ 最终一致;读多写少场景优先"先更新 DB 再删缓存"。
坑 5:本地缓存过期时间太长,读到脏数据
修复: 本地缓存只放强一致性要求低的数据,过期时间控制在 3-5s;通过 MQ 主动失效。
坑 6:Redis 单节点 CPU 被打满,以为是集群容量不足
修复: 监控 key 级访问分布;发现倾斜后做本地缓存或 key 副本拆分。
09 · 总结:缓存不是加速,是架构基础设施
Redis 用得好,能把 10w QPS 的 DB 压力削到几千;用得不好,它就是最隐蔽的故障源。生产落地的关键是:
- 架构层: Cluster 分片 + 读写分离 + 多可用区部署。
- 代码层: Redisson 锁显式配置、本地缓存削峰、互斥锁防击穿。
- 一致性层: Cache-Aside + 延时双删 + MQ 兜底,接受最终一致。
- 治理层: Hot/Big Key 探测、背压、熔断、全链路监控。
缓存设计的最高境界不是"快",而是**"在快的同时,让系统在异常时也能优雅降级"**。