初始版本
/**
* 获取热门话题列表(按热度排序)
* @return 热门话题列表
*/
@ApiOperation("热门话题列表(按热度排序)")
@GetMapping("/hot")
public ApiResponse<List<Topics>> getHotTopics() {
try {
List<Topics> hotTopics = topicsService.getHotTopics();
if (hotTopics != null) {
return ApiResponse.success(hotTopics);
}
return ApiResponse.error(300, "没有帖子存在");
}catch (Exception e) {
logger.error(e.getMessage());
throw new RuntimeException(e);
}
}
这是一个获取热门话题的列表,这个时候问题来了,它的访问量会比较高,但是呢,他要根据热度进行排序,也就是说,他是可能会经常变化的,那么缓存合适吗,添加缓存应该是那些变化比较小的吧,但是它的访问量如何解决呢?目前响应实际时间大概是186ms - (目前数据库的数据大概在5w)
对于这样一个场景肯定是需要优化的,那么也就是需要控制频率了,因为目前的这个业务对于实时性有一定的要求,但是并非是股票类的强一致性和实时性的业务,那么这里我们可以设置它的刷新时间在30s左右的样子(根据业务动态调整)
问题拆解
问题 1:是否适合添加缓存?
- 优点:
- 热门话题接口访问量高,通过缓存可以大幅减少数据库的查询压力,提高响应速度。
- 高访问场景中,热点数据的缓存命中率可以提升系统整体性能。
- 缺点:
- 由于热门话题根据“热度”排序,数据会频繁变化,缓存一致性难以保证。
- 如果缓存的更新机制不合理,可能导致用户看到的不是最新的热门话题。
问题 2:访问量高如何解决?
- 数据查询和排序的高频访问会对数据库造成巨大压力,特别是在数据量较大的情况下,需要设计合理的优化方案:
- 减少对数据库的直接查询,缓解数据库压力。
- 使用多级缓存或流量分摊机制降低单点压力。
解决方案设计
方案 1:热点缓存设计
尽管数据频繁变化,但可以基于业务的特点对缓存进行设计:
- 缓存什么内容?
- 缓存热门话题的完整列表或部分列表(如前 50 条)。
- 缓存的对象可以是排序后的数据,避免每次重新计算排序。
- 缓存多久?
- 使用短时缓存,缓存时间可以是30秒到1分钟(根据实际业务调整)。
- 短时缓存的作用是吸收高并发的短时间内的流量,但允许数据稍有延迟。
- 缓存更新策略:
- 主动更新:定时任务定期查询数据库并更新缓存(比如每 30 秒更新一次)。
- 被动更新:当数据写入或热度变化时,触发缓存更新(但注意并发写问题)。
- 双缓存机制:使用两个缓存区,一个缓存最新数据,一个缓存历史数据,保证切换时没有“缓存空白”。
方案 2:本地缓存 + 分布式缓存结合
在高并发场景下,可以结合**本地缓存(如 Caffeine)和分布式缓存(如 Redis)**来提高访问性能:
- Caffeine 本地缓存:
- 应用程序本地缓存部分热门话题,减少对 Redis 或数据库的依赖。
- 本地缓存适合存储前 10 或前 20 条话题,数据可以设置为每 10 秒刷新一次。
- 使用 Caffeine 的权重淘汰策略(LFU)来保留访问频率最高的数据。
Caffeine 示例代码:
private final Cache<String, List<Topics>> localCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(100) // 限制最大缓存数量
.build();
方案 4:流量削峰与分摊
对于特别高的访问量场景,可以进一步采取以下措施:
- CDN 缓存:
- 静态化热门话题列表(如 JSON 格式的文件),并将其缓存到 CDN 上。
- CDN 的刷新频率可以是每分钟或更高频率。
- 限流与降级:
- 设置接口限流,防止因瞬时高并发导致服务崩溃。
- 在极端情况下(如缓存失效),可以降级为返回空数据或上次的缓存内容。
- 异步队列:
- 请求进入队列,由后台异步处理,客户端直接返回“处理中”提示,稍后刷新页面查看结果。
优化版本
RateLimiter topicsRateLimiter = RateLimiter.create(1000.0);
private final Logger logger = LoggerFactory.getLogger(TopicsController.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private final Cache<String, List<Topics>> topicsCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(100) // 限制最大缓存数量
.build();
private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
@PostConstruct
public void init() {
List<Topics> hotTopics = topicsService.getHotTopics();
topicsCache.put(TOPICS_ALL, hotTopics);
redisUtils.set(TOPICS_ALL, hotTopics);
scheduler.scheduleAtFixedRate(()->{
List<Topics> hotTopic = topicsService.getHotTopics();
topicsCache.put(TOPICS_ALL, hotTopic);
redisUtils.set(TOPICS_ALL, hotTopic);
}, 20, 30, TimeUnit.SECONDS);
}
/**
* 获取热门话题列表(按热度排序)
* @return 热门话题列表
*/
@ApiOperation("热门话题列表(按热度排序)")
@GetMapping("/hot")
public ApiResponse<List<Topics>> getHotTopics() {
if (!topicsRateLimiter.tryAcquire()){
return ApiResponse.error(400,"请求过于频繁");
}
try {
List<Topics> hotTopics = topicsCache.getIfPresent(TOPICS_ALL);
if (hotTopics == null) {
hotTopics = (List<Topics>) redisUtils.get(TOPICS_ALL);
if (hotTopics == null) {
hotTopics = topicsService.getHotTopics();
redisUtils.set(TOPICS_ALL, hotTopics);
}
topicsCache.put(TOPICS_ALL, hotTopics);
}
return ApiResponse.success(hotTopics);
}catch (Exception e) {
logger.error(e.getMessage());
throw new RuntimeException(e);
}
}
性能优化:186ms - > 17ms - > 12ms,速度已经非常快了
这个代码结合了结合了限流、Caffeine 本地缓存、Redis 缓存、定时更新等多层优化措施。 性能上更上一层楼。
而且目前的代码也有一些问题存在:
1. 热点数据初始化时的双写问题
- 目前的
init
方法在启动时同时向本地缓存(Caffeine)和 Redis 写入热点数据。如果在某些场景下(如高并发、多实例),init
的执行时序可能会导致数据不一致。 - 建议:初始化只写入 Redis,并在读取时通过双层缓存(Redis + 本地缓存)保证一致性,避免冗余初始化代码。
- 也就是说如果两个写入操作间隔的时间,热度就可能变化导致数据不一致,(当然,这是在多实例,分布式的情况下)
2. 定时任务刷新缓存的优化
- 问题:
-
- 每次都从数据库中拉取最新数据并更新到缓存中,即使数据没有变化,也会进行更新。
- 定时任务的间隔时间(30 秒)可能与业务需求不完全匹配。
- 优化方案:
-
- 增加数据变更检测机制:通过判断数据是否更新(如通过时间戳或版本号)决定是否刷新缓存。
- 延迟任务模型:当检测到数据变化时,立即更新缓存,而不是固定间隔更新。
示例代码(基于版本号更新缓存):
scheduler.scheduleAtFixedRate(() -> {
List<Topics> hotTopics = topicsService.getHotTopics();
List<Topics> cachedTopics = topicsCache.getIfPresent(TOPICS_ALL);
if (!hotTopics.equals(cachedTopics)) { // 检查数据是否变化
topicsCache.put(TOPICS_ALL, hotTopics);
redisUtils.set(TOPICS_ALL, hotTopics);
}
}, 20, 30, TimeUnit.SECONDS);
定时任务的优化
当前问题
scheduler.scheduleAtFixedRate
会不断轮询数据,但如果topicsService.getHotTopics()
出现延迟(如查询耗时超过 30 秒),可能会导致任务堆积。
增加熔断与降级机制
当前问题
- 当缓存未命中并且数据库出现问题时,可能导致接口不可用。
public ApiResponse<List<Topics>> getHotTopics() {
if (!topicsRateLimiter.tryAcquire()) {
return ApiResponse.error(400, "请求过于频繁");
}
try {
return ApiResponse.success(cacheManager.get(TOPICS_ALL, () -> topicsService.getHotTopics()));
} catch (Exception e) {
logger.error("获取热门话题失败", e);
// 熔断降级逻辑
List<Topics> fallbackTopics = getFallbackHotTopics();
return ApiResponse.success(fallbackTopics);
}
}
private List<Topics> getFallbackHotTopics() {
return Arrays.asList(new Topics("默认话题1"), new Topics("默认话题2"));
}
最终代码
@Autowired
private RedisUtils redisUtils;
public static String TOPICS_ALL = "topics/all";
public static String TOPIC_BY_ID = "topics/by-id";
public static String FALL_BACK_TOPICS_ALL = "fall/topics/all";
RateLimiter topicsRateLimiter = RateLimiter.create(1000.0);
private final Logger logger = LoggerFactory.getLogger(TopicsController.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private final Cache<String, List<Topics>> topicsCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(100) // 限制最大缓存数量
.build();
private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
@PostConstruct
public void init() {
scheduler.scheduleWithFixedDelay(() -> {
try {
List<Topics> hotTopics = topicsService.getHotTopics();
List<Topics> cachedTopics = topicsCache.getIfPresent(TOPICS_ALL);
if (!hotTopics.equals(cachedTopics)) {
redisUtils.set(TOPICS_ALL, hotTopics);
topicsCache.put(FALL_BACK_TOPICS_ALL, hotTopics); // 更新本地备用缓存
}
} catch (Exception e) {
logger.error("定时更新热门话题缓存失败: {}", e.getMessage());
}
}, 20, 30, TimeUnit.SECONDS);
}
/**
* 获取热门话题列表(按热度排序)
*
* @return 热门话题列表
*/
@ApiOperation("热门话题列表(按热度排序)")
@GetMapping("/hot")
public ApiResponse<List<Topics>> getHotTopics() {
if (!topicsRateLimiter.tryAcquire()) {
return ApiResponse.error(400, "请求过于频繁");
}
try {
List<Topics> hotTopics = topicsCache.getIfPresent(TOPICS_ALL);
if (hotTopics == null) {
hotTopics = (List<Topics>) redisUtils.get(TOPICS_ALL);
if (hotTopics == null) {
hotTopics = topicsService.getHotTopics();
redisUtils.set(TOPICS_ALL, hotTopics);
}
topicsCache.put(TOPICS_ALL, hotTopics);
}
return ApiResponse.success(hotTopics);
} catch (Exception e) {
logger.error(e.getMessage());
return ApiResponse.success(topicsCache.getIfPresent(FALL_BACK_TOPICS_ALL));
}
}