高并发设计 -- 获取热门话题例子

初始版本 

/**
 * 获取热门话题列表(按热度排序)
 * @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:热点缓存设计

尽管数据频繁变化,但可以基于业务的特点对缓存进行设计:

  1. 缓存什么内容?
    1. 缓存热门话题的完整列表或部分列表(如前 50 条)。
    2. 缓存的对象可以是排序后的数据,避免每次重新计算排序。
  2. 缓存多久?
    1. 使用短时缓存,缓存时间可以是30秒到1分钟(根据实际业务调整)。
    2. 短时缓存的作用是吸收高并发的短时间内的流量,但允许数据稍有延迟。
  3. 缓存更新策略:
    1. 主动更新:定时任务定期查询数据库并更新缓存(比如每 30 秒更新一次)。
    2. 被动更新:当数据写入或热度变化时,触发缓存更新(但注意并发写问题)。
    3. 双缓存机制:使用两个缓存区,一个缓存最新数据,一个缓存历史数据,保证切换时没有“缓存空白”。
方案 2:本地缓存 + 分布式缓存结合

在高并发场景下,可以结合**本地缓存(如 Caffeine)分布式缓存(如 Redis)**来提高访问性能:

  1. Caffeine 本地缓存
    1. 应用程序本地缓存部分热门话题,减少对 Redis 或数据库的依赖。
    2. 本地缓存适合存储前 10 或前 20 条话题,数据可以设置为每 10 秒刷新一次。
    3. 使用 Caffeine 的权重淘汰策略(LFU)来保留访问频率最高的数据。

Caffeine 示例代码:

private final Cache<String, List<Topics>> localCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(100) // 限制最大缓存数量
.build();
方案 4:流量削峰与分摊

对于特别高的访问量场景,可以进一步采取以下措施:

  1. CDN 缓存
    1. 静态化热门话题列表(如 JSON 格式的文件),并将其缓存到 CDN 上。
    2. CDN 的刷新频率可以是每分钟或更高频率。
  2. 限流与降级
    1. 设置接口限流,防止因瞬时高并发导致服务崩溃。
    2. 在极端情况下(如缓存失效),可以降级为返回空数据或上次的缓存内容。
  3. 异步队列
    1. 请求进入队列,由后台异步处理,客户端直接返回“处理中”提示,稍后刷新页面查看结果。

优化版本

    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));
        }
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值