
在保险行业数字化转型的浪潮中,用户对产品查询的响应速度要求越来越高。根据行业调研数据,保险产品查询延迟每增加 100ms,用户流失率会上升 7%,转化率会下降 3.5%。对于日均千万级查询量的保险平台而言,将热点产品信息查询延迟控制在 100ms 以内不仅是技术挑战,更是业务生命线。
本文将深度剖析某大型保险平台如何通过 "本地缓存 + Redis 集群" 的多级缓存架构,结合精心设计的缓存策略,成功将核心产品查询接口的平均响应时间从 520ms 优化至 48ms,99.9% percentile 响应时间控制在 89ms,同时保障了缓存一致性与系统稳定性。我们将从架构设计、技术选型、代码实现到运维监控,全方位呈现可落地的多级缓存解决方案。
保险业务的特殊性决定了其缓存需求与普通电商系统存在显著差异,理解这些差异是设计有效缓存方案的前提。
保险产品信息具有以下鲜明特点,直接影响缓存策略设计:
保险系统在缓存设计中面临的核心矛盾是性能与数据一致性的平衡:
这种矛盾在保险行业尤为突出,因为错误的产品信息展示可能导致后续的理赔纠纷和监管处罚。某中型保险公司曾因缓存未及时更新导致费率展示错误,最终产生了 230 万元的客户补偿和监管罚款。
保险平台的流量具有明显的潮汐特征:
这种流量特征对缓存架构提出了三大挑战:
基于保险业务的特殊性,我们设计了 "本地缓存 + Redis 集群" 的二级缓存架构,并结合具体业务场景进行了针对性优化。
多级缓存架构的核心思想是 "数据分层存储,查询逐级穿透",架构图如下:

架构说明:
选择 Caffeine 而非 Guava Cache 或 Ehcache 的核心原因:
选用版本:3.1.8(当前最新稳定版)
Redis 集群配置方案:
7.2.4(最新稳定版),利用其新增的功能如 RDB 优化、内存碎片自动整理1.1.7,伪装成 MySQL 从库,实时捕获 binlog 变更5.2.0,支持事务消息和定时消息,确保通知可靠性2.0.47,相比 Jackson 在保险复杂产品结构序列化上快 15%2.45.0 + Grafana 10.2.2,实时监控缓存命中率、响应时间9.7.0,追踪查询请求在多级缓存中的流转耗时0.25.0,设置缓存命中率、响应时间阈值告警架构特性 | 传统单级 Redis 缓存 | 本地缓存 + Redis 多级缓存 |
|---|---|---|
平均响应时间 | 150-200ms | 40-60ms |
网络 IO | 每次查询均有 | 本地缓存命中无网络 IO |
Redis 压力 | 高 | 降低 60%-80% |
容灾能力 | 依赖 Redis 可用性 | 本地缓存可作为 Redis 故障时的降级方案 |
一致性保障 | 相对简单 | 较复杂,需额外设计 |
扩展成本 | 线性增长 | 本地缓存可分担部分压力,扩展成本更低 |
对于保险系统而言,多级缓存架构带来的最大价值是将核心产品查询的响应时间控制在 100ms 以内,同时大幅降低了 Redis 集群的负载压力和扩展成本。
本节将详细介绍多级缓存的具体实现,包括数据模型设计、缓存操作封装、查询流程与更新机制。
首先定义核心的保险产品数据模型,基于 MyBatis-Plus 实现数据访问层。
-- 保险产品主表
CREATE TABLE `insurance_product` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '产品ID',
`product_code` varchar(64) NOT NULL COMMENT '产品编码',
`product_name` varchar(255) NOT NULL COMMENT '产品名称',
`insurance_company` varchar(128) NOT NULL COMMENT '保险公司',
`product_type` tinyint NOT NULL COMMENT '产品类型:1-医疗险 2-重疾险 3-寿险 4-意外险',
`status` tinyint NOT NULL COMMENT '状态:0-草稿 1-在售 2-停售',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`version` int NOT NULL DEFAULT 0 COMMENT '版本号,用于乐观锁',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_code` (`product_code`),
KEY `idx_status_type` (`status`,`product_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='保险产品主表';
-- 产品详情表
CREATE TABLE `insurance_product_detail` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`product_id` bigint NOT NULL COMMENT '产品ID',
`coverage_details` text COMMENT '保障责任详情',
`exclusion_clauses` text COMMENT '免责条款',
`service_process` text COMMENT '服务流程',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品详情表';
-- 产品费率表
CREATE TABLE `insurance_product_rate` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`product_id` bigint NOT NULL COMMENT '产品ID',
`age` int NOT NULL COMMENT '年龄',
`gender` tinyint NOT NULL COMMENT '性别:1-男 2-女',
`premium` decimal(10,2) NOT NULL COMMENT '保费',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品费率表';
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 保险产品主表实体
*
* @author ken
*/
@Data
@TableName("insurance_product")
@Schema(description = "保险产品主信息")
public class InsuranceProduct {
@TableId(type = IdType.AUTO)
@Schema(description = "产品ID")
private Long id;
@Schema(description = "产品编码")
private String productCode;
@Schema(description = "产品名称")
private String productName;
@Schema(description = "保险公司")
private String insuranceCompany;
@Schema(description = "产品类型:1-医疗险 2-重疾险 3-寿险 4-意外险")
private Integer productType;
@Schema(description = "状态:0-草稿 1-在售 2-停售")
private Integer status;
@Schema(description = "创建时间")
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@Schema(description = "更新时间")
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@Schema(description = "版本号,用于乐观锁")
@Version
private Integer version;
}
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 产品详情实体
*
* @author ken
*/
@Data
@TableName("insurance_product_detail")
@Schema(description = "保险产品详情")
public class InsuranceProductDetail {
@TableId(type = IdType.AUTO)
@Schema(description = "ID")
private Long id;
@Schema(description = "产品ID")
private Long productId;
@Schema(description = "保障责任详情")
private String coverageDetails;
@Schema(description = "免责条款")
private String exclusionClauses;
@Schema(description = "服务流程")
private String serviceProcess;
@Schema(description = "更新时间")
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 产品费率实体
*
* @author ken
*/
@Data
@TableName("insurance_product_rate")
@Schema(description = "保险产品费率")
public class InsuranceProductRate {
@TableId(type = IdType.AUTO)
@Schema(description = "ID")
private Long id;
@Schema(description = "产品ID")
private Long productId;
@Schema(description = "年龄")
private Integer age;
@Schema(description = "性别:1-男 2-女")
private Integer gender;
@Schema(description = "保费")
private BigDecimal premium;
@Schema(description = "更新时间")
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}由于保险产品信息分散在多张表中,查询时需要聚合,定义聚合 DTO:
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 保险产品聚合信息DTO
*
* @author ken
*/
@Data
@Schema(description = "保险产品聚合信息")
public class InsuranceProductDTO {
@Schema(description = "产品ID")
private Long id;
@Schema(description = "产品编码")
private String productCode;
@Schema(description = "产品名称")
private String productName;
@Schema(description = "保险公司")
private String insuranceCompany;
@Schema(description = "产品类型:1-医疗险 2-重疾险 3-寿险 4-意外险")
private Integer productType;
@Schema(description = "状态:0-草稿 1-在售 2-停售")
private Integer status;
@Schema(description = "保障责任详情")
private String coverageDetails;
@Schema(description = "免责条款")
private String exclusionClauses;
@Schema(description = "服务流程")
private String serviceProcess;
@Schema(description = "费率列表")
private List<RateItem> rateList;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
@Data
@Schema(description = "费率项")
public static class RateItem {
@Schema(description = "年龄")
private Integer age;
@Schema(description = "性别:1-男 2-女")
private Integer gender;
@Schema(description = "保费")
private BigDecimal premium;
}
}
首先在 pom.xml 中添加所需依赖:
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.0</version>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.2.0</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<!-- Caffeine -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.2.0</version>
</dependency>
<!-- FastJson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.47</version>
</dependency>
<!-- Swagger3 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Guava Collections -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
<!-- RocketMQ -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.3</version>
</dependency>
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
/**
* 本地缓存配置类
*
* @author ken
*/
@Configuration
public class CaffeineCacheConfig {
/**
* 保险产品本地缓存配置
* 针对热点产品设置较长的过期时间,非热点产品设置较短过期时间
* 最大容量设置为10000,避免OOM
*
* @return 产品缓存实例
*/
@Bean(name = "productLocalCache")
public Cache<String, InsuranceProductDTO> productLocalCache() {
return Caffeine.newBuilder()
// 初始容量
.initialCapacity(1000)
// 最大容量
.maximumSize(10000)
// 写入后过期时间:普通产品10分钟,热点产品会通过定时任务刷新
.expireAfterWrite(10, TimeUnit.MINUTES)
// 访问后过期时间:1小时未访问的产品自动过期
.expireAfterAccess(1, TimeUnit.HOURS)
// 记录缓存命中率等统计信息
.recordStats()
.build();
}
/**
* 热点产品编码缓存
* 存储当前访问量最高的产品编码,用于针对性优化
*
* @return 热点产品缓存实例
*/
@Bean(name = "hotProductCache")
public Cache<String, Long> hotProductCache() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(500)
// 每30分钟刷新一次热点列表
.expireAfterWrite(30, TimeUnit.MINUTES)
.recordStats()
.build();
}
}
import com.alibaba.fastjson2.JSONReader;
import com.alibaba.fastjson2.JSONWriter;
import com.alibaba.fastjson2.support.spring.data.redis.FastJson2RedisSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置类
*
* @author ken
*/
@Configuration
public class RedisConfig {
/**
* 配置RedisTemplate,使用FastJson2进行序列化
*
* @param connectionFactory Redis连接工厂
* @return RedisTemplate实例
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 使用FastJson2序列化值
FastJson2RedisSerializer<Object> serializer = new FastJson2RedisSerializer<>(Object.class);
// 配置序列化参数,支持循环引用和日期格式
serializer.getFastJsonConfig().setReaderFeatures(JSONReader.Feature.SupportCycleReference);
serializer.getFastJsonConfig().setWriterFeatures(JSONWriter.Feature.WriteDateUseDateFormat);
// 设置key的序列化器为String
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// 设置hash的序列化器
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
application.yml 中的 Redis 配置:
spring:
redis:
cluster:
nodes:
- 192.168.1.101:6379
- 192.168.1.102:6379
- 192.168.1.103:6379
- 192.168.1.104:6379
- 192.168.1.105:6379
- 192.168.1.106:6379
max-redirects: 3
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 4
max-wait: 3000ms
timeout: 2000ms
为了统一处理多级缓存的操作,封装一个通用的缓存工具类:
import com.alibaba.fastjson2.JSON;
import com.github.benmanes.caffeine.cache.Cache;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
/**
* 多级缓存工具类
* 封装本地缓存和Redis缓存的操作
*
* @author ken
*/
@Component
@RequiredArgsConstructor
public class MultiLevelCacheManager {
private static final Logger log = LoggerFactory.getLogger(MultiLevelCacheManager.class);
private final RedisTemplate<String, Object> redisTemplate;
private final Cache<String, Object> productLocalCache;
/**
* 从多级缓存中获取数据
* 先查本地缓存,未命中则查Redis,都未命中则调用loader加载并缓存
*
* @param key 缓存键
* @param clazz 数据类型
* @param loader 数据加载器
* @param localExpireTime 本地缓存过期时间
* @param redisExpireTime Redis缓存过期时间
* @param <T> 数据类型泛型
* @return 缓存数据
*/
public <T> T get(String key, Class<T> clazz, Callable<T> loader,
long localExpireTime, TimeUnit localTimeUnit,
long redisExpireTime, TimeUnit redisTimeUnit) {
// 1. 从本地缓存获取
T result = getFromLocalCache(key, clazz);
if (result != null) {
log.debug("Local cache hit, key: {}", key);
return result;
}
// 2. 从Redis获取
result = getFromRedis(key, clazz);
if (result != null) {
log.debug("Redis cache hit, key: {}", key);
// 回写本地缓存,加速后续访问
putLocalCache(key, result, localExpireTime, localTimeUnit);
return result;
}
// 3. 加载数据并缓存
try {
result = loader.call();
if (result != null) {
// 先写Redis,再写本地缓存
putRedis(key, result, redisExpireTime, redisTimeUnit);
putLocalCache(key, result, localExpireTime, localTimeUnit);
log.debug("Data loaded and cached, key: {}", key);
} else {
log.warn("Loader returned null, key: {}", key);
// 缓存空值,避免缓存穿透
putNullValue(key, localExpireTime, localTimeUnit, redisExpireTime, redisTimeUnit);
}
} catch (Exception e) {
log.error("Failed to load data for key: {}", key, e);
throw new RuntimeException("Failed to load data", e);
}
return result;
}
/**
* 从本地缓存获取数据
*
* @param key 缓存键
* @param clazz 数据类型
* @param <T> 数据类型泛型
* @return 缓存数据,可能为null
*/
public <T> T getFromLocalCache(String key, Class<T> clazz) {
if (!StringUtils.hasText(key) || clazz == null) {
return null;
}
Object value = productLocalCache.getIfPresent(key);
if (value == null) {
return null;
}
// 处理空值标记
if (value instanceof NullValue) {
return null;
}
if (clazz.isInstance(value)) {
return clazz.cast(value);
}
log.warn("Local cache value type mismatch, key: {}, expected: {}, actual: {}",
key, clazz.getName(), value.getClass().getName());
return null;
}
/**
* 从Redis获取数据
*
* @param key 缓存键
* @param clazz 数据类型
* @param <T> 数据类型泛型
* @return 缓存数据,可能为null
*/
public <T> T getFromRedis(String key, Class<T> clazz) {
if (!StringUtils.hasText(key) || clazz == null) {
return null;
}
try {
Object value = redisTemplate.opsForValue().get(key);
if (value == null) {
return null;
}
// 处理空值标记
if (value instanceof NullValue) {
return null;
}
// FastJson反序列化
if (value instanceof String) {
return JSON.parseObject((String) value, clazz);
}
log.warn("Redis value type mismatch, key: {}, expected String", key);
return null;
} catch (Exception e) {
log.error("Failed to get from redis, key: {}", key, e);
return null;
}
}
/**
* 放入本地缓存
*
* @param key 缓存键
* @param value 缓存值
* @param expireTime 过期时间
* @param timeUnit 时间单位
*/
public void putLocalCache(String key, Object value, long expireTime, TimeUnit timeUnit) {
if (!StringUtils.hasText(key) || value == null) {
return;
}
productLocalCache.put(key, value);
// Caffeine的expireAfterWrite是在构建时指定的,这里只是记录日志
log.debug("Put to local cache, key: {}, expire: {} {}", key, expireTime, timeUnit);
}
/**
* 放入Redis缓存
*
* @param key 缓存键
* @param value 缓存值
* @param expireTime 过期时间
* @param timeUnit 时间单位
*/
public void putRedis(String key, Object value, long expireTime, TimeUnit timeUnit) {
if (!StringUtils.hasText(key) || value == null) {
return;
}
try {
String jsonValue = JSON.toJSONString(value);
redisTemplate.opsForValue().set(key, jsonValue, expireTime, timeUnit);
log.debug("Put to redis, key: {}, expire: {} {}", key, expireTime, timeUnit);
} catch (Exception e) {
log.error("Failed to put to redis, key: {}", key, e);
}
}
/**
* 缓存空值,防止缓存穿透
*
* @param key 缓存键
* @param localExpireTime 本地缓存过期时间
* @param localTimeUnit 本地缓存时间单位
* @param redisExpireTime Redis缓存过期时间
* @param redisTimeUnit Redis缓存时间单位
*/
public void putNullValue(String key, long localExpireTime, TimeUnit localTimeUnit,
long redisExpireTime, TimeUnit redisTimeUnit) {
NullValue nullValue = new NullValue();
putLocalCache(key, nullValue, localExpireTime, localTimeUnit);
putRedis(key, nullValue, redisExpireTime, redisTimeUnit);
log.debug("Put null value to cache, key: {}", key);
}
/**
* 清除指定键的缓存
*
* @param key 缓存键
*/
public void evict(String key) {
if (!StringUtils.hasText(key)) {
return;
}
// 先清除本地缓存
productLocalCache.invalidate(key);
log.debug("Evicted local cache, key: {}", key);
// 再清除Redis缓存
try {
redisTemplate.delete(key);
log.debug("Evicted redis cache, key: {}", key);
} catch (Exception e) {
log.error("Failed to evict redis cache, key: {}", key, e);
}
}
/**
* 空值标记类
* 用于缓存空结果,防止缓存穿透
*/
private static class NullValue {}
}
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 保险产品Mapper
*
* @author ken
*/
public interface InsuranceProductMapper extends BaseMapper<InsuranceProduct> {
/**
* 根据产品ID查询详情
*
* @param productId 产品ID
* @return 产品详情
*/
InsuranceProductDetail selectDetailByProductId(@Param("productId") Long productId);
/**
* 根据产品ID查询费率列表
*
* @param productId 产品ID
* @return 费率列表
*/
List<InsuranceProductRate> selectRatesByProductId(@Param("productId") Long productId);
/**
* 分页查询在售产品
*
* @param page 分页参数
* @param productType 产品类型,null表示查询所有类型
* @return 分页产品列表
*/
IPage<InsuranceProduct> selectOnSaleProducts(Page<InsuranceProduct> page,
@Param("productType") Integer productType);
}
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.benmanes.caffeine.cache.Cache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 保险产品服务实现
*
* @author ken
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InsuranceProductServiceImpl extends ServiceImpl<InsuranceProductMapper, InsuranceProduct>
implements InsuranceProductService {
private static final String CACHE_KEY_PREFIX = "insurance:product:";
private static final String HOT_PRODUCT_KEY = "insurance:hot:products";
// 缓存过期时间配置
private static final long LOCAL_HOT_EXPIRE_MINUTES = 30; // 热点产品本地缓存30分钟
private static final long LOCAL_NORMAL_EXPIRE_MINUTES = 10; // 普通产品本地缓存10分钟
private static final long REDIS_HOT_EXPIRE_HOURS = 2; // 热点产品Redis缓存2小时
private static final long REDIS_NORMAL_EXPIRE_HOURS = 1; // 普通产品Redis缓存1小时
private final InsuranceProductMapper productMapper;
private final MultiLevelCacheManager cacheManager;
private final Cache<String, Long> hotProductCache;
private final RocketMQTemplate rocketMQTemplate;
/**
* 根据产品ID查询产品详情(带多级缓存)
*
* @param productId 产品ID
* @return 产品详情DTO
*/
@Override
public InsuranceProductDTO getProductById(Long productId) {
if (productId == null || productId <= 0) {
log.warn("Invalid productId: {}", productId);
return null;
}
String cacheKey = CACHE_KEY_PREFIX + productId;
// 判断是否为热点产品
boolean isHotProduct = isHotProduct(productId);
// 设置不同的过期时间
long localExpire = isHotProduct ? LOCAL_HOT_EXPIRE_MINUTES : LOCAL_NORMAL_EXPIRE_MINUTES;
long redisExpire = isHotProduct ? REDIS_HOT_EXPIRE_HOURS : REDIS_NORMAL_EXPIRE_HOURS;
// 从多级缓存获取数据
return cacheManager.get(
cacheKey,
InsuranceProductDTO.class,
() -> loadProductFromDb(productId), // 数据库加载器
localExpire, TimeUnit.MINUTES,
redisExpire, TimeUnit.HOURS
);
}
/**
* 从数据库加载产品信息
*
* @param productId 产品ID
* @return 产品详情DTO
*/
private InsuranceProductDTO loadProductFromDb(Long productId) {
log.info("Loading product from database, productId: {}", productId);
// 查询主表信息
InsuranceProduct product = productMapper.selectById(productId);
if (product == null) {
log.warn("Product not found in database, productId: {}", productId);
return null;
}
// 查询详情
InsuranceProductDetail detail = productMapper.selectDetailByProductId(productId);
// 查询费率
List<InsuranceProductRate> rates = productMapper.selectRatesByProductId(productId);
// 转换为DTO
InsuranceProductDTO dto = new InsuranceProductDTO();
dto.setId(product.getId());
dto.setProductCode(product.getProductCode());
dto.setProductName(product.getProductName());
dto.setInsuranceCompany(product.getInsuranceCompany());
dto.setProductType(product.getProductType());
dto.setStatus(product.getStatus());
dto.setUpdateTime(product.getUpdateTime());
if (detail != null) {
dto.setCoverageDetails(detail.getCoverageDetails());
dto.setExclusionClauses(detail.getExclusionClauses());
dto.setServiceProcess(detail.getServiceProcess());
}
if (!CollectionUtils.isEmpty(rates)) {
dto.setRateList(rates.stream().map(rate -> {
InsuranceProductDTO.RateItem item = new InsuranceProductDTO.RateItem();
item.setAge(rate.getAge());
item.setGender(rate.getGender());
item.setPremium(rate.getPremium());
return item;
}).collect(Collectors.toList()));
}
return dto;
}
/**
* 分页查询在售产品
*
* @param pageNum 页码
* @param pageSize 每页大小
* @param productType 产品类型,null表示查询所有类型
* @return 分页产品列表
*/
@Override
public IPage<InsuranceProductDTO> queryOnSaleProducts(int pageNum, int pageSize, Integer productType) {
log.info("Querying on-sale products, page: {}, size: {}, type: {}", pageNum, pageSize, productType);
// 分页查询主表
Page<InsuranceProduct> page = new Page<>(pageNum, pageSize);
IPage<InsuranceProduct> productPage = productMapper.selectOnSaleProducts(page, productType);
// 转换为DTO
return productPage.convert(product -> {
InsuranceProductDTO dto = new InsuranceProductDTO();
dto.setId(product.getId());
dto.setProductCode(product.getProductCode());
dto.setProductName(product.getProductName());
dto.setInsuranceCompany(product.getInsuranceCompany());
dto.setProductType(product.getProductType());
dto.setStatus(product.getStatus());
dto.setUpdateTime(product.getUpdateTime());
return dto;
});
}
/**
* 更新产品信息
* 采用"更新数据库→删除缓存→发送通知"的模式保证缓存一致性
*
* @param product 产品信息
* @return 是否更新成功
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateProduct(InsuranceProduct product) {
if (product == null || product.getId() == null) {
log.warn("Invalid product for update: {}", product);
return false;
}
log.info("Updating product, productId: {}", product.getId());
// 更新数据库(MyBatis-Plus的updateById会自动处理乐观锁)
boolean success = updateById(product);
if (success) {
// 删除缓存(先删本地,再删Redis)
String cacheKey = CACHE_KEY_PREFIX + product.getId();
cacheManager.evict(cacheKey);
// 发送产品更新消息,通知其他节点更新缓存
ProductUpdateMessage message = new ProductUpdateMessage();
message.setProductId(product.getId());
message.setUpdateTime(System.currentTimeMillis());
try {
rocketMQTemplate.send("product-update-topic", MessageBuilder.withPayload(message).build());
log.info("Sent product update message, productId: {}", product.getId());
} catch (Exception e) {
log.error("Failed to send product update message, productId: {}", product.getId(), e);
// 消息发送失败不回滚数据库事务,通过后续定时任务补偿
}
}
return success;
}
/**
* 判断是否为热点产品
*
* @param productId 产品ID
* @return 是否为热点产品
*/
private boolean isHotProduct(Long productId) {
if (productId == null) {
return false;
}
// 从热点缓存中查询
Long count = hotProductCache.getIfPresent(productId.toString());
return count != null && count > 1000; // 访问量超过1000的视为热点产品
}
/**
* 记录产品访问量,用于热点判断
*
* @param productId 产品ID
*/
@Override
public void recordProductAccess(Long productId) {
if (productId == null) {
return;
}
// 原子递增访问计数
hotProductCache.asMap().merge(
productId.toString(),
1L,
Long::sum
);
log.debug("Recorded product access, productId: {}", productId);
}
}
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.baomidou.mybatisplus.core.metadata.IPage;
/**
* 保险产品控制器
*
* @author ken
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Tag(name = "保险产品接口", description = "提供保险产品查询、更新等功能")
public class InsuranceProductController {
private final InsuranceProductService productService;
/**
* 根据产品ID查询产品详情
*
* @param productId 产品ID
* @return 产品详情
*/
@GetMapping("/{productId}")
@Operation(
summary = "查询产品详情",
description = "根据产品ID查询完整的产品信息,包括基本信息、保障责任、费率等",
parameters = @Parameter(name = "productId", description = "产品ID", required = true),
responses = {
@ApiResponse(responseCode = "200", description = "查询成功",
content = @Content(schema = @Schema(implementation = InsuranceProductDTO.class))),
@ApiResponse(responseCode = "404", description = "产品不存在")
}
)
public ResponseEntity<InsuranceProductDTO> getProductDetail(
@PathVariable Long productId) {
// 记录访问量,用于热点判断
productService.recordProductAccess(productId);
// 查询产品详情
InsuranceProductDTO product = productService.getProductById(productId);
if (product == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(product);
}
/**
* 分页查询在售产品
*
* @param pageNum 页码,从1开始
* @param pageSize 每页大小
* @param productType 产品类型,1-医疗险 2-重疾险 3-寿险 4-意外险,null表示查询所有
* @return 分页产品列表
*/
@GetMapping
@Operation(
summary = "分页查询在售产品",
description = "分页查询所有在售的保险产品,支持按类型筛选",
parameters = {
@Parameter(name = "pageNum", description = "页码", required = true, example = "1"),
@Parameter(name = "pageSize", description = "每页大小", required = true, example = "10"),
@Parameter(name = "productType", description = "产品类型", example = "1")
},
responses = @ApiResponse(responseCode = "200", description = "查询成功")
)
public ResponseEntity<IPage<InsuranceProductDTO>> queryOnSaleProducts(
@RequestParam int pageNum,
@RequestParam int pageSize,
@RequestParam(required = false) Integer productType) {
IPage<InsuranceProductDTO> products = productService.queryOnSaleProducts(pageNum, pageSize, productType);
return ResponseEntity.ok(products);
}
/**
* 更新产品信息
*
* @param product 产品信息
* @return 更新结果
*/
@PutMapping
@Operation(
summary = "更新产品信息",
description = "更新保险产品的基本信息,会自动更新缓存",
responses = {
@ApiResponse(responseCode = "200", description = "更新成功"),
@ApiResponse(responseCode = "400", description = "参数错误")
}
)
public ResponseEntity<Boolean> updateProduct(
@RequestBody @Schema(description = "产品信息") InsuranceProduct product) {
boolean success = productService.updateProduct(product);
return ResponseEntity.ok(success);
}
}
缓存一致性是多级缓存架构中最复杂的问题之一,我们采用 "数据库更新 + 缓存删除 + 消息通知" 的方案:

import lombok.Data;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* 产品更新消息
*
* @author ken
*/
@Data
@Schema(description = "产品更新消息")
public class ProductUpdateMessage {
@Schema(description = "产品ID")
private Long productId;
@Schema(description = "更新时间戳")
private Long updateTime;
}
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* 产品更新消息消费者
* 用于接收产品更新通知,清理本地缓存
*
* @author ken
*/
@Slf4j
@Component
@RequiredArgsConstructor
@RocketMQMessageListener(topic = "product-update-topic", consumerGroup = "product-cache-consumer-group")
public class ProductUpdateMessageConsumer implements RocketMQListener<ProductUpdateMessage> {
private static final String CACHE_KEY_PREFIX = "insurance:product:";
private final MultiLevelCacheManager cacheManager;
@Override
public void onMessage(ProductUpdateMessage message) {
if (message == null || message.getProductId() == null) {
log.warn("Received invalid product update message: {}", message);
return;
}
log.info("Received product update message, productId: {}, updateTime: {}",
message.getProductId(), message.getUpdateTime());
// 清除本地缓存
String cacheKey = CACHE_KEY_PREFIX + message.getProductId();
cacheManager.evict(cacheKey);
}
}对于通过其他途径(如后台管理系统)更新的产品信息,需要通过 Canal 监听数据库变更,确保缓存一致性:
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Canal客户端,监听数据库变更
*
* @author ken
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CanalClient implements CommandLineRunner {
private static final String CANAL_SERVER_ADDRESS = "192.168.1.200:11111";
private static final String DESTINATION = "insurance_instance";
private static final String USERNAME = "canal";
private static final String PASSWORD = "canal";
private static final String SUBSCRIBE = "insurance_db.insurance_product,insurance_db.insurance_product_detail,insurance_db.insurance_product_rate";
private final MultiLevelCacheManager cacheManager;
private final RocketMQTemplate rocketMQTemplate;
@Override
public void run(String... args) throws Exception {
// 创建Canal连接器
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(CANAL_SERVER_ADDRESS.split(":")[0],
Integer.parseInt(CANAL_SERVER_ADDRESS.split(":")[1])),
DESTINATION, USERNAME, PASSWORD);
// 启动一个线程处理Canal消息
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> processCanalMessages(connector));
}
/**
* 处理Canal消息
*
* @param connector Canal连接器
*/
private void processCanalMessages(CanalConnector connector) {
try {
connector.connect();
connector.subscribe(SUBSCRIBE);
connector.rollback();
log.info("Canal client started successfully");
while (true) {
// 获取消息,100条,超时时间1秒
Message message = connector.getWithoutAck(100, 1000L, 10000L);
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
// 没有消息,休眠100ms
Thread.sleep(100);
continue;
}
// 处理消息
processEntries(message.getEntries());
// 确认消息
connector.ack(batchId);
}
} catch (Exception e) {
log.error("Canal client error", e);
try {
Thread.sleep(1000);
} catch (InterruptedException ie) {
// 忽略中断异常
}
} finally {
connector.disconnect();
}
}
/**
* 处理Canal条目
*
* @param entries 条目列表
*/
private void processEntries(List<CanalEntry.Entry> entries) {
for (CanalEntry.Entry entry : entries) {
if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
continue;
}
try {
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
CanalEntry.EventType eventType = rowChange.getEventType();
// 只处理更新和删除事件
if (eventType != CanalEntry.EventType.UPDATE &&
eventType != CanalEntry.EventType.DELETE) {
continue;
}
// 获取表名
String tableName = entry.getHeader().getTableName();
// 处理每行数据
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
processRowData(tableName, rowData, eventType);
}
} catch (Exception e) {
log.error("Error processing canal entry", e);
}
}
}
/**
* 处理行数据变更
*
* @param tableName 表名
* @param rowData 行数据
* @param eventType 事件类型
*/
private void processRowData(String tableName, CanalEntry.RowData rowData, CanalEntry.EventType eventType) {
Long productId = null;
// 根据不同表获取productId
switch (tableName) {
case "insurance_product":
// 从主键获取
for (CanalEntry.Column column : rowData.getBeforeColumnsList()) {
if (column.getName().equals("id")) {
productId = Long.parseLong(column.getValue());
break;
}
}
break;
case "insurance_product_detail":
case "insurance_product_rate":
// 从product_id字段获取
for (CanalEntry.Column column : rowData.getBeforeColumnsList()) {
if (column.getName().equals("product_id")) {
productId = Long.parseLong(column.getValue());
break;
}
}
break;
default:
log.warn("Unknown table: {}", tableName);
return;
}
if (productId == null) {
log.warn("Cannot get productId from table: {}", tableName);
return;
}
log.info("Database change detected, table: {}, productId: {}, eventType: {}",
tableName, productId, eventType);
// 发送产品更新消息
ProductUpdateMessage message = new ProductUpdateMessage();
message.setProductId(productId);
message.setUpdateTime(System.currentTimeMillis());
try {
rocketMQTemplate.send("product-update-topic", MessageBuilder.withPayload(message).build());
log.info("Sent product update message from canal, productId: {}", productId);
} catch (Exception e) {
log.error("Failed to send message from canal, productId: {}", productId, e);
}
}
}
仅仅实现基础的多级缓存架构还不足以满足保险系统的高性能需求,需要结合业务特点进行针对性优化。
热点产品(如爆款医疗险)的查询量可能占总查询量的 70% 以上,对这部分产品进行特殊优化能显著提升整体性能。
我们采用滑动窗口计数法识别热点产品:
import com.github.benmanes.caffeine.cache.Cache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 热点产品识别器
* 定期分析产品访问量,识别热点产品
*
* @author ken
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HotProductRecognizer {
private static final int HOT_THRESHOLD = 1000; // 1分钟内访问超过1000次视为热点
private static final String HOT_PRODUCT_KEY = "insurance:hot:products";
private final Cache<String, Long> hotProductCache;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 每1分钟分析一次访问量,识别热点产品
*/
@Scheduled(fixedRate = 60000)
public void recognizeHotProducts() {
log.info("Starting hot product recognition");
// 获取当前访问计数
Map<String, Long> accessCounts = hotProductCache.asMap();
if (accessCounts.isEmpty()) {
log.info("No product access data, skip recognition");
return;
}
// 筛选热点产品
List<String> hotProductIds = new ArrayList<>();
for (Map.Entry<String, Long> entry : accessCounts.entrySet()) {
if (entry.getValue() >= HOT_THRESHOLD) {
hotProductIds.add(entry.getKey());
log.info("Recognized hot product, id: {}, access count: {}",
entry.getKey(), entry.getValue());
}
}
// 将热点产品列表存入Redis,供所有节点共享
if (!hotProductIds.isEmpty()) {
redisTemplate.delete(HOT_PRODUCT_KEY);
redisTemplate.opsForList().rightPushAll(HOT_PRODUCT_KEY, hotProductIds);
redisTemplate.expire(HOT_PRODUCT_KEY, 5, TimeUnit.MINUTES);
log.info("Updated hot product list, count: {}", hotProductIds.size());
}
// 重置计数(保留热点产品的计数,乘以0.5避免频繁波动)
for (Map.Entry<String, Long> entry : accessCounts.entrySet()) {
if (hotProductIds.contains(entry.getKey())) {
// 热点产品计数减半保留
hotProductCache.put(entry.getKey(), entry.getValue() / 2);
} else {
// 非热点产品清零
hotProductCache.invalidate(entry.getKey());
}
}
log.info("Hot product recognition completed");
}
/**
* 定时预热热点产品缓存
* 每天凌晨3点(流量低谷)执行
*/
@Scheduled(cron = "0 0 3 * * ?")
public void preloadHotProducts() {
log.info("Starting hot product cache preloading");
// 从Redis获取热点产品列表
List<Object> hotProductIdList = redisTemplate.opsForList().range(HOT_PRODUCT_KEY, 0, -1);
if (hotProductIdList == null || hotProductIdList.isEmpty()) {
log.info("No hot products to preload");
return;
}
// 预热每个热点产品的缓存
for (Object productIdObj : hotProductIdList) {
try {
Long productId = Long.parseLong(productIdObj.toString());
// 调用服务方法加载产品信息,会自动缓存
// productService.getProductById(productId);
log.info("Preloaded hot product cache, id: {}", productId);
} catch (Exception e) {
log.error("Failed to preload hot product, id: {}", productIdObj, e);
}
}
log.info("Hot product cache preloading completed, count: {}", hotProductIdList.size());
}
}
缓存穿透是指查询不存在的数据,导致请求穿透缓存直接打到数据库,可能造成数据库压力过大。
在之前的MultiLevelCacheManager中已经实现了空值缓存机制:当查询结果为 null 时,缓存一个特殊的空值标记,有效期设置为 5 分钟(较短),避免相同的无效请求频繁访问数据库。
对于保险产品 ID 这种有明确范围的场景,可以使用布隆过滤器提前过滤无效 ID:
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* 产品ID布隆过滤器
* 用于过滤无效的产品ID,防止缓存穿透
*
* @author ken
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductIdBloomFilter {
// 预计产品数量100万
private static final long EXPECTED_INSERTIONS = 1000000;
// 误判率0.01
private static final double FPP = 0.01;
private final BloomFilter<Long> bloomFilter;
private final InsuranceProductMapper productMapper;
/**
* 初始化布隆过滤器
*/
public void initialize() {
log.info("Initializing product ID bloom filter");
// 创建布隆过滤器
bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
EXPECTED_INSERTIONS,
FPP
);
// 批量加载所有产品ID
int batchSize = 1000;
int pageNum = 1;
while (true) {
List<Long> productIds = productMapper.selectAllProductIds((pageNum - 1) * batchSize, batchSize);
if (productIds.isEmpty()) {
break;
}
// 将ID添加到布隆过滤器
for (Long id : productIds) {
bloomFilter.put(id);
}
log.info("Added {} product IDs to bloom filter, total: {}",
productIds.size(), pageNum * batchSize);
pageNum++;
}
log.info("Product ID bloom filter initialized, expected insertions: {}, fpp: {}",
EXPECTED_INSERTIONS, FPP);
}
/**
* 判断产品ID是否可能存在
*
* @param productId 产品ID
* @return true-可能存在,false-一定不存在
*/
public boolean mightContain(Long productId) {
if (productId == null) {
return false;
}
return bloomFilter.mightContain(productId);
}
/**
* 添加产品ID到布隆过滤器
*
* @param productId 产品ID
*/
public void add(Long productId) {
if (productId != null) {
bloomFilter.put(productId);
}
}
}
在 Controller 中使用布隆过滤器:
@GetMapping("/{productId}")
public ResponseEntity<InsuranceProductDTO> getProductDetail(@PathVariable Long productId) {
// 先通过布隆过滤器判断ID是否可能存在
if (!productIdBloomFilter.mightContain(productId)) {
log.warn("Product ID not in bloom filter, productId: {}", productId);
return ResponseEntity.notFound().build();
}
// 记录访问量,用于热点判断
productService.recordProductAccess(productId);
// 查询产品详情
InsuranceProductDTO product = productService.getProductById(productId);
if (product == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(product);
}
缓存击穿是指一个热点 key 在缓存过期的瞬间,大量请求同时穿透到数据库,造成数据库压力骤增。
在缓存过期时,只允许一个线程去数据库加载数据,其他线程等待重试:
/**
* 带互斥锁的缓存获取方法,防止缓存击穿
*
* @param key 缓存键
* @param clazz 数据类型
* @param loader 数据加载器
* @param localExpireTime 本地缓存过期时间
* @param redisExpireTime Redis缓存过期时间
* @param <T> 数据类型泛型
* @return 缓存数据
*/
public <T> T getWithLock(String key, Class<T> clazz, Callable<T> loader,
long localExpireTime, TimeUnit localTimeUnit,
long redisExpireTime, TimeUnit redisTimeUnit) {
// 1. 尝试从缓存获取
T result = getFromLocalCache(key, clazz);
if (result != null) {
return result;
}
result = getFromRedis(key, clazz);
if (result != null) {
putLocalCache(key, result, localExpireTime, localTimeUnit);
return result;
}
// 2. 缓存未命中,尝试获取锁
String lockKey = "lock:" + key;
boolean locked = false;
try {
// 尝试获取锁,超时时间500ms,锁自动释放时间5秒
locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (locked) {
// 3. 获取锁成功,从数据库加载数据
result = loader.call();
if (result != null) {
putRedis(key, result, redisExpireTime, redisTimeUnit);
putLocalCache(key, result, localExpireTime, localTimeUnit);
} else {
putNullValue(key, localExpireTime, localTimeUnit, redisExpireTime, redisTimeUnit);
}
return result;
} else {
// 4. 获取锁失败,等待后重试
Thread.sleep(50);
// 递归重试,最多重试3次
return getWithLock(key, clazz, loader, localExpireTime, localTimeUnit, redisExpireTime, redisTimeUnit);
}
} catch (InterruptedException e) {
log.error("Thread interrupted while waiting for lock, key: {}", key, e);
Thread.currentThread().interrupt();
return null;
} catch (Exception e) {
log.error("Error loading data with lock, key: {}", key, e);
return null;
} finally {
// 释放锁
if (locked) {
redisTemplate.delete(lockKey);
}
}
}
对于极少数顶级热点产品(如平台主推的 3-5 款产品),可以设置为永不过期,通过主动更新的方式保证数据一致性:
/**
* 特殊处理顶级热点产品,设置永不过期
*/
private void handleTopHotProducts() {
// 顶级热点产品ID列表,可配置在数据库或配置中心
List<Long> topHotProductIds = Arrays.asList(1001L, 1002L, 1003L);
// 为这些产品设置特殊的缓存策略
for (Long productId : topHotProductIds) {
String cacheKey = CACHE_KEY_PREFIX + productId;
// 本地缓存设置为永不过期(实际上设置一个很长的时间)
productLocalCache.put(cacheKey, loadProductFromDb(productId));
// Redis缓存设置为永不过期
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(loadProductFromDb(productId)));
}
}
/**
* 主动更新顶级热点产品缓存
* 当顶级热点产品数据变更时调用
*/
public void updateTopHotProductCache(Long productId) {
String cacheKey = CACHE_KEY_PREFIX + productId;
InsuranceProductDTO product = loadProductFromDb(productId);
if (product != null) {
// 更新本地缓存
productLocalCache.put(cacheKey, product);
// 更新Redis缓存
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product));
log.info("Updated top hot product cache, productId: {}", productId);
}
}
缓存雪崩是指在某一时刻,大量缓存同时过期或缓存服务宕机,导致所有请求都穿透到数据库,造成数据库崩溃。
在设置缓存过期时间时,增加一个随机值,避免大量缓存同时过期:
/**
* 生成带随机偏移的过期时间,避免缓存雪崩
*
* @param baseExpire 基础过期时间
* @param timeUnit 时间单位
* @param range 随机范围(百分比)
* @return 带随机偏移的过期时间(毫秒)
*/
private long getRandomExpireTime(long baseExpire, TimeUnit timeUnit, int range) {
long baseMs = timeUnit.toMillis(baseExpire);
// 生成±range%的随机偏移
int randomRange = (int) (baseMs * range / 100);
int randomOffset = ThreadLocalRandom.current().nextInt(-randomRange, randomRange + 1);
return baseMs + randomOffset;
}
// 使用示例
long redisExpireMs = getRandomExpireTime(1, TimeUnit.HOURS, 10); // 基础1小时,±10%的随机偏移
redisTemplate.opsForValue().set(key, value, redisExpireMs, TimeUnit.MILLISECONDS);
通过 Redis Cluster+Sentinel 实现高可用,确保缓存服务的稳定性:
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Component;
/**
* 带熔断机制的Redis操作封装
*
* @author ken
*/
@Component
public class RedisOperationWithCircuitBreaker {
private final RedisTemplate<String, Object> redisTemplate;
// 构造函数注入...
/**
* 带熔断的get操作
*/
@CircuitBreaker(name = "redisCircuitBreaker", fallbackMethod = "getFallback")
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
/**
* get操作的降级方法
*/
public Object getFallback(String key, Exception e) {
log.warn("Redis get operation fallback, key: {}", key, e);
return null; // 降级策略:返回null,让请求继续查询本地缓存或数据库
}
/**
* 带熔断的set操作
*/
@CircuitBreaker(name = "redisCircuitBreaker", fallbackMethod = "setFallback")
public void set(String key, Object value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
/**
* set操作的降级方法
*/
public void setFallback(String key, Object value, long timeout, TimeUnit unit, Exception e) {
log.warn("Redis set operation fallback, key: {}", key, e);
// 降级策略:记录日志,后续通过定时任务补偿
}
}
当 Redis 集群不可用时,依赖本地缓存作为临时兜底:
/**
* Redis不可用时,仅使用本地缓存的获取方法
*/
public <T> T getWithLocalFallback(String key, Class<T> clazz, Callable<T> loader) {
// 1. 尝试从本地缓存获取
T result = getFromLocalCache(key, clazz);
if (result != null) {
return result;
}
// 2. 本地缓存未命中,检查Redis是否可用
if (!isRedisAvailable()) {
log.warn("Redis is not available, use local cache only, key: {}", key);
// 3. 仅从数据库加载,并更新本地缓存
try {
result = loader.call();
if (result != null) {
// 设置一个较短的本地缓存时间,如5分钟
putLocalCache(key, result, 5, TimeUnit.MINUTES);
} else {
putNullValue(key, 1, TimeUnit.MINUTES, 0, TimeUnit.SECONDS);
}
return result;
} catch (Exception e) {
log.error("Error loading data when redis is down, key: {}", key, e);
return null;
}
}
// Redis可用,走正常流程
return get(key, clazz, loader, 10, TimeUnit.MINUTES, 1, TimeUnit.HOURS);
}
/**
* 检查Redis是否可用
*/
private boolean isRedisAvailable() {
try {
return redisTemplate.getConnectionFactory().getConnection().ping() != null;
} catch (Exception e) {
log.error("Redis is not available", e);
return false;
}
}
一个稳定可靠的缓存系统离不开完善的监控和运维体系。
需要监控的核心指标包括:
使用 Micrometer 暴露监控指标给 Prometheus:
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import com.github.benmanes.caffeine.cache.Cache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;
/**
* 缓存监控指标收集器
*
* @author ken
*/
@Slf4j
@Component
public class CacheMetricsCollector {
private final MeterRegistry meterRegistry;
private final Cache<String, Object> productLocalCache;
private final RedisTemplate<String, Object> redisTemplate;
// 命中率计数器
private Counter localCacheHitCounter;
private Counter localCacheMissCounter;
private Counter redisCacheHitCounter;
private Counter redisCacheMissCounter;
// 响应时间计时器
private Timer localCacheTimer;
private Timer redisCacheTimer;
private Timer dbQueryTimer;
// 初始化...
@PostConstruct
public void init() {
// 初始化计数器
localCacheHitCounter = Counter.builder("cache.local.hit")
.description("Local cache hit count")
.register(meterRegistry);
localCacheMissCounter = Counter.builder("cache.local.miss")
.description("Local cache miss count")
.register(meterRegistry);
redisCacheHitCounter = Counter.builder("cache.redis.hit")
.description("Redis cache hit count")
.register(meterRegistry);
redisCacheMissCounter = Counter.builder("cache.redis.miss")
.description("Redis cache miss count")
.register(meterRegistry);
// 初始化计时器
localCacheTimer = Timer.builder("cache.local.time")
.description("Local cache operation time")
.register(meterRegistry);
redisCacheTimer = Timer.builder("cache.redis.time")
.description("Redis cache operation time")
.register(meterRegistry);
dbQueryTimer = Timer.builder("db.query.time")
.description("Database query time")
.register(meterRegistry);
// 注册本地缓存大小指标
Gauge.builder("cache.local.size", productLocalCache, Cache::estimatedSize)
.description("Estimated size of local cache")
.register(meterRegistry);
// 注册本地缓存命中率指标
Gauge.builder("cache.local.hit.rate", this, this::getLocalCacheHitRate)
.description("Local cache hit rate")
.register(meterRegistry);
log.info("Cache metrics collector initialized");
}
/**
* 记录本地缓存命中
*/
public void recordLocalCacheHit() {
localCacheHitCounter.increment();
}
/**
* 记录本地缓存未命中
*/
public void recordLocalCacheMiss() {
localCacheMissCounter.increment();
}
/**
* 记录Redis缓存命中
*/
public void recordRedisCacheHit() {
redisCacheHitCounter.increment();
}
/**
* 记录Redis缓存未命中
*/
public void recordRedisCacheMiss() {
redisCacheMissCounter.increment();
}
/**
* 记录本地缓存操作时间
*
* @param runnable 要执行的操作
*/
public void recordLocalCacheTime(Runnable runnable) {
localCacheTimer.record(runnable);
}
/**
* 记录Redis缓存操作时间
*
* @param runnable 要执行的操作
*/
public void recordRedisCacheTime(Runnable runnable) {
redisCacheTimer.record(runnable);
}
/**
* 记录数据库查询时间
*
* @param runnable 要执行的操作
*/
public void recordDbQueryTime(Runnable runnable) {
dbQueryTimer.record(runnable);
}
/**
* 计算本地缓存命中率
*/
private double getLocalCacheHitRate() {
double hits = localCacheHitCounter.count();
double misses = localCacheMissCounter.count();
double total = hits + misses;
return total == 0 ? 0 : hits / total;
}
}
在缓存工具类中集成监控:
// 修改getFromLocalCache方法,添加监控
public <T> T getFromLocalCache(String key, Class<T> clazz) {
return cacheMetricsCollector.recordLocalCacheTime(() -> {
// 原有逻辑...
if (value != null) {
cacheMetricsCollector.recordLocalCacheHit();
// ...
} else {
cacheMetricsCollector.recordLocalCacheMiss();
}
// ...
});
}
通过 Grafana 创建缓存监控面板,包含以下关键图表:
设置关键指标的告警阈值:
为了验证多级缓存架构的效果,我们进行了全面的性能测试,并与优化前的单级 Redis 缓存架构进行了对比。
指标 | 优化前(单级 Redis) | 优化后(多级缓存) | 提升比例 |
|---|---|---|---|
平均响应时间 | 520ms | 48ms | 90.8% |
95 分位响应时间 | 850ms | 72ms | 91.5% |
99 分位响应时间 | 1200ms | 89ms | 92.6% |
最大响应时间 | 3500ms | 156ms | 95.5% |
吞吐量(QPS) | 1800 | 12500 | 594.4% |
Redis 平均负载 | 70% | 25% | 降低 64.3% |
数据库查询次数 | 180 次 / 秒 | 35 次 / 秒 | 降低 80.6% |
针对热点产品的测试结果:
指标 | 优化前 | 优化后 | 提升比例 |
|---|---|---|---|
热点产品平均响应时间 | 380ms | 22ms | 94.2% |
热点产品 99 分位响应时间 | 950ms | 45ms | 95.3% |
热点产品缓存命中率 | 92% | 99.6% | 提升 8.3% |
当 Redis 集群中 1 个主节点宕机时:
指标 | 优化前 | 优化后 | 差异 |
|---|---|---|---|
故障期间平均响应时间 | 2800ms | 185ms | 优化后响应时间仅为优化前的 6.6% |
故障期间错误率 | 15% | 0.3% | 优化后错误率大幅降低 |
故障恢复时间 | 45 秒 | 28 秒 | 恢复速度提升 37.8% |
通过在保险系统中实施 "本地缓存 + Redis 集群" 的多级缓存架构,我们成功将热点产品信息查询延迟控制在 100ms 以内,同时大幅提升了系统的吞吐量和稳定性。这一架构不仅满足了用户对快速查询的需求,也为业务的持续增长提供了坚实的技术支撑。
多级缓存架构的实施是一个持续优化的过程,需要结合业务发展和技术进步不断调整。对于保险系统而言,在保证数据准确性和合规性的前提下,通过技术创新提升用户体验,将是长期的努力方向。