Redis缓存落地总结
最近在优化电子签系统,涉及到缓存相关的也一并优化了,写个文档做个总结,防止以后开发时又考虑不全
1、避免大key
避免缓存大PDF文件:
💡 经验值:单个Redis Value不超过10KB,集合元素不超过5000个
// 反例:直接缓存整个PDF
@Cacheable(value = "contract_pdf", key = "#contractId")
public byte[] getContractPdf(String contractId) { /* 返回5MB文件 */ }// 正例:拆分为元数据+文件存储
@Cacheable(value = "contract_meta", key = "#contractId")
public ContractMeta getContractMeta(String contractId) { /* 返回1KB元数据 */ }public byte[] getContractPdf(String fileResId) {// 从对象存储按需获取return fileStoreUtil.getFileByResId(fileResId);
}
不是不能缓存大数据,而是避免用缓存替代存储系统,需要根据数据访问频率、读写比例、数据一致性要求进行分级管理。大对象缓存会导致内存碎片化,甚至触发Full GC。
2、动态TTL策略
问题场景还原
合同模板缓存的TTL固定为1小时,导致:
法律风险:法务紧急更新模板后,旧版本缓存未及时失效
穿透风险:缓存过期,引发批量缓存穿透
解决方案:三级TTL策略
// 合同模板缓存设置(Spring Boot实现)
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {return RedisCacheManager.builder(factory)// 基础TTL + 随机抖动(防集中失效).cacheDefaults(defaultCacheConfig().entryTtl(Duration.ofMinutes(30 + ThreadLocalRandom.current().nextInt(10))))// 特殊Key定制(高敏感数据缩短TTL).withInitialCacheConfigurations(Map.of("contract_template", CacheConfig.defaultCacheConfig().entryTtl(Duration.ofMinutes(5)) // 模板变更频繁)).build();
}// 获取模板时动态续期(高频访问数据自动延长有效期)
public String getContractTemplate(String templateId) {String template = redisTemplate.opsForValue().get(templateId);if (template != null) {// 每次访问续期15分钟(LFU思想)redisTemplate.expire(templateId, 15, TimeUnit.MINUTES); }return template;
}
3、避免批量失效
血泪教训
项目压测期间,积分缓存设置相同TTL,导致凌晨集中失效,数据库瞬时被打爆
-
数据库瞬时QPS飙升:从2000飙升至12000+
-
线程池满载:大量查询堆积触发熔断
// 缓存加载时注入随机TTL(单位:秒)
public void cacheElectricData(String plantId, ElectricData data) {// 基础24小时 + 随机2小时分钟(3600 * 24 ± 3600 * 2秒)int baseTtl = 3600 * 24; int randomOffset = new Random().nextInt(3600 * 2); redisTemplate.opsForValue().set("plant_electric:" + plantId, data, baseTtl + randomOffset, TimeUnit.SECONDS);
}
失效时间均匀分布在 02:00~04:00(业务低谷)
4、增加熔断降级
防止万一缓存挂了,不能影响整个服务的可用性
这个就不仅仅是缓存了,例如合同签署过程中,CA机构服务异常,不能阻塞后续业务进行
// 1. 框架层熔断(Sentinel)
@SentinelResource(value = "caSignService", fallback = "localFallback", blockHandler = "systemBlock",exceptionsToIgnore = {InvalidSignatureException.class} // 业务异常不触发熔断
)
public SignResult callCaService(Certificate cert, byte[] pdf) {return caClient.sign(cert, pdf); // CA机构远程调用
}// 2. 降级策略 - 本地存证+异步补偿
private SignResult localFallback(Certificate cert, byte[] pdf, Throwable ex) {// 生成临时签名标识(法律允许72小时内补签)String tempSignId = "TEMP_" + UUID.randomUUID();// 存储待签文件至MinIOminioClient.putObject("pending-sign", tempSignId, pdf);// 发送延迟补偿消息(每5分钟重试)rocketMQTemplate.sendDelay("SIGN_RETRY_TOPIC", MessageBuilder.withPayload(tempSignId).build(),5, TimeUnit.MINUTES);return new SignResult(202, "签名延迟处理中", tempSignId);
}// 3. 系统级熔断 - 返回法律兜底模板
private SignResult systemBlock(Certificate cert, byte[] pdf, BlockException ex) {return new SignResult(503, "系统维护中,请使用纸质签约流程");
}
5、空值缓存
在用户请求并发量大的业务场景种,需要把空值缓存起来,防止大批量在系统中不存在的id,没有命中缓存,而直接查询数据库的情况。
测评时遇到非法合同ID扫描攻击
- 需记录审计日志
- 防止缓存污染
分层拦截方案
public Contract getContract(String contractId) {// 第一层:布隆过滤器(10亿数据占120MB)if (!bloomFilter.mightContain(contractId)) {auditService.logInvalidRequest(contractId); // 记录黑产行为throw new ContractNotFoundException();}// 第二层:空值缓存(带法律标识)Contract contract = redisTemplate.opsForValue().get(contractId);if (contract == NULL_LEGAL_OBJ) { // 特殊空值对象return null;}if (contract != null) {return contract;}// 第三层:分布式锁保护DB查询RLock lock = redisson.getLock("LOCK_CONTRACT:" + contractId);lock.lock(3, TimeUnit.SECONDS);try {// 双重检查contract = redisTemplate.get(contractId);if (contract != null) return contract;// 数据库查询contract = contractDao.findById(contractId);if (contract == null) {// 法律合规空值(带审计标记)LegalNullObject nullObj = new LegalNullObject("INVALID_CONTRACT", System.currentTimeMillis());redisTemplate.opsForValue().set(contractId, nullObj, 30, TimeUnit.SECONDS // 短TTL);return null;} else {redisTemplate.opsForValue().set(contractId, contract, 1, TimeUnit.HOURS);return contract;}} finally {lock.unlock();}
}
6、分布式锁用Redisson
签章流水号控制,防止同一份合同被重复签署
红锁(RedLock)实现
public boolean trySignContract(String contractId) {// 获取5个独立Redis节点的锁RLock[] locks = new RLock[5];for (int i = 0; i < 5; i++) {locks[i] = redissonClient.getLock("SIGN_LOCK:" + contractId + ":node" + i);}RLock redLock = new RedissonRedLock(locks);try {// 尝试加锁(等待300ms,锁持有1分钟)if (redLock.tryLock(300, 60_000, TimeUnit.MILLISECONDS)) {if (signLogDao.hasSigned(contractId)) {return false; // 已签署}caService.sign(contractId);return true;}return false;} finally {redLock.unlock();}
}
7、延迟双删策略
签署任务状态一致性保障
- 签署任务状态变更后必须确保缓存与数据库绝对一致
// 四阶段双删策略
@Transactional
public void updateContractStatus(String contractId, ContractStatus status) {// 阶段1:预删除缓存redisTemplate.delete(contractId);// 阶段2:DB更新(带事务)contractDao.updateStatus(contractId, status);// 阶段3:异步延迟双删rocketMQTemplate.sendAsync("CACHE_DELETE_TOPIC", MessageBuilder.withPayload(contractId).setHeader("DELAY_LEVEL", "3") // 5秒延迟.build());// 阶段4:区块链存证(独立事务)blockchainService.recordStatusChange(contractId, status);
}// MQ消费者
@RocketMQMessageListener(topic = "CACHE_DELETE_TOPIC")
public class CacheDeleteListener implements RocketMQListener<String> {@Overridepublic void onMessage(String contractId) {// 二次删除redisTemplate.delete(contractId);// 重建缓存(最终一致性)Contract contract = contractDao.findById(contractId);if (contract != null) {redisTemplate.opsForValue().set(contractId, contract, 1, TimeUnit.HOURS);}}
}
8、最终一致性方案
// 1. 可靠消息发送(防丢失)
public void updateContractStatus(String contractId, Status status) {// 在事务中保存状态变更记录contractDao.updateStatus(contractId, status); // 发送半消息(RocketMQ事务消息)TransactionSendResult sendResult = rocketMQTemplate.sendMessageInTransaction("contract_tx_group",MessageBuilder.withPayload(new StatusChangeEvent(contractId, status)).build(),null);// 事务回查(确保消息落地)if (sendResult.getState() != LocalTransactionState.COMMIT_MESSAGE) {throw new IllegalStateException("消息发送失败");}
}// 2. MQ消费者(保证幂等)
@RocketMQMessageListener(topic = "STATUS_CHANGE_TOPIC", consumerGroup = "contract_group")
public class StatusChangeConsumer implements RocketMQListener<StatusChangeEvent> {@Overridepublic void onMessage(StatusChangeEvent event) {// 幂等检查(防止重复消费)if (statusLogDao.isProcessed(event.getMessageId())) return;// 步骤1:删除缓存redisTemplate.delete("contract:" + event.getContractId());// 步骤2:区块链存证blockchainService.recordStatus(event.getContractId(), event.getStatus());// 步骤3:重建缓存(存证成功后)Contract contract = contractDao.findById(event.getContractId());redisTemplate.opsForValue().set("contract:" + event.getContractId(), contract,1, TimeUnit.HOURS);// 记录处理日志statusLogDao.markProcessed(event.getMessageId());}
}
9、热点数据预加载
合同模板缓存
- 上班时间(9:00)模板请求量激增
- 新模板上线时缓存穿透
// 1. 基于时间窗口的预测加载
@Scheduled(cron = "0 0 8 * * ?") // 每天8:00执行
public void preloadMorningTemplates() {// 预测今日热点(昨日TOP100模板)List<String> hotTemplates = statsDao.getYesterdayHotTemplates(100);// 并行预加载hotTemplates.parallelStream().forEach(templateId -> {ContractTemplate template = templateDao.load(templateId);redisTemplate.opsForValue().set("template:" + templateId,template,12, TimeUnit.HOURS // 覆盖日间高峰);});
}// 2. 实时热点探测(流式计算)
@KafkaListener(topics = "template_access_log")
public void handleAccessLog(AccessLog log) {// 滑动窗口计数(10分钟)long count = redisTemplate.opsForZSet().count("template_access:" + log.templateId(), System.currentTimeMillis() - 600_000, Long.MAX_VALUE);// 超过阈值触发预加载if (count > 1000) { executor.submit(() -> {if (!redisTemplate.hasKey("template:" + log.templateId())) {loadTemplateToCache(log.templateId());}});}
}
10、根据场景选择数据结构
错误案例
redis.set("template:123", JSON.toJSONString(template));
每次更新单个字段都需要反序列化整个对象。
导致问题:
- 序列化/反序列化开销大
- 更新单个字段需读写整个对象
- 内存占用高 正确实践:
正确案例
// 使用Hash存储
redis.opsForHash().putAll("template:123", templateToMap(template)); // 局部更新
redis.opsForHash().put("template:123", "resId", "31212");
缓存落地总结
- 避免大Key 合同PDF存储 元数据存Redis,文件存MinIO 内存降低80%,GC频率减少90%
- 动态TTL 合同模板动态ttl
- 防批量失效 每日积分缓存 TTL=24h±2h随机 数据库峰值压力降低92%
- 熔断降级 CA机构调用 Sentinel熔断+本地存证+异步重试 签署服务可用性99.99%
- 空值缓存 非法合同ID拦截 布隆过滤器+LegalNullObject+分布式锁 穿透查询减少100%
- 分布式锁 签章流水号控制 Redisson红锁(5节点) 重复签署事故归零
- 延迟双删 合同状态更新 预删除→DB更新→延迟消息→二次删除 状态不一致率<0.001%
- 最终一致性 区块链存证状态同步 RocketMQ事务消息+消费者幂等 存证延迟从3.2s降至0.8s
- 热点预加载 合同模板缓存 定时任务(8:00)+流式热点探测 9:00峰值响应从450ms→35ms
- 数据结构优化 签章记录存储 签章ID-ZSet + 详情-Hash 内存占用减少70%,分页性能提升6倍