跳过正文

技术派社区:从0到3000+QPS的全链路性能优化实战

·667 字·4 分钟
黄振宇
作者
黄振宇
一个技术社区的诞生:从 CRUD 到 3000+ QPS,中间经历了什么?

🚀 3000+ QPS 💾 多级缓存 📨 消息异步 📱 扫码登录


项目概述
#

技术派 是一个前后端分离的、面向互联网开发者的技术内容分享与交流平台,包括前端 PC 和管理后台。通过文章、教程、AI 助手等产品形态,旨在打造一个激发开发者创作灵感的技术社区。

作为一个学习型项目,它最大的价值在于覆盖了高并发场景下的主流优化手段,而不是停留在"能跑就行"的阶段。

技术栈
#

层级 技术选型
后端 Spring Boot + MyBatis-Plus
缓存 Caffeine 本地缓存 + Redis 分布式缓存
消息队列 RabbitMQ
搜索引擎 Elasticsearch
数据库 MySQL
安全 Spring Security + JWT

系统架构
#

graph TB
    subgraph 客户端
        A[PC 前端]
        B[管理后台]
    end

    subgraph 网关层
        C[Spring Security<br/>JWT 鉴权]
    end

    subgraph 业务层
        D[文章服务]
        E[用户服务]
        F[评论/互动服务]
        G[AI 助手]
    end

    subgraph 数据层
        H[(MySQL)]
        I[(Redis)]
        J[Caffeine<br/>本地缓存]
        K[RabbitMQ]
        L[(Elasticsearch)]
    end

    A & B --> C --> D & E & F & G
    D & E & F --> I
    D --> J
    D --> H
    F --> K
    D & E --> L

核心优化
#

一、扫码登录:无密码的安全体验
#

公众号回调 Token 双检

很多开发者都用过扫码登录,但自己实现一遍才会发现里面的坑。

流程设计

sequenceDiagram
    participant 用户
    participant 前端
    participant 后端
    participant 微信公众号

    用户->>前端: 点击"扫码登录"
    前端->>后端: 请求生成二维码
    后端->>后端: 生成 sceneId + Ticket
    后端-->>前端: 返回二维码 URL
    用户->>微信公众号: 扫描二维码
    微信公众号-->>后端: 回调确认(携带 sceneId)
    后端->>后端: 生成 JWT Token
    前端->>后端: 轮询/Ticket 查询登录状态
    后端-->>前端: 返回 Token + 用户信息

关键实现

// 1. 生成扫码 Ticket
public String generateQrTicket() {
    String sceneId = UUID.randomUUID().toString().replace("-", "");
    String ticket = "ticket:" + sceneId;

    // 5分钟过期
    redisTemplate.opsForValue().set(ticket, "PENDING", 5, TimeUnit.MINUTES);
    return sceneId;
}

// 2. 微信回调确认登录
public void confirmLogin(String sceneId, Long userId) {
    String ticket = "ticket:" + sceneId;
    String token = jwtService.generateToken(userId);

    // 将 Token 写入 Ticket,前端轮询能拿到
    redisTemplate.opsForValue().set(ticket, token, 5, TimeUnit.MINUTES);
}

// 3. 前端轮询检查
public String checkLoginStatus(String sceneId) {
    String ticket = "ticket:" + sceneId;
    String value = redisTemplate.opsForValue().get(ticket);

    if ("PENDING".equals(value)) return null;         // 等待扫码
    if (value != null && value.startsWith("ey")) return value; // 已登录
    return "EXPIRED";                                  // 已过期
}
⚠️ 扫码登录的安全要点:Ticket 必须设置短过期时间(5分钟),扫码后原 Ticket 立即失效防止重放攻击。Token 的有效期应独立管理,不要和 Ticket 绑定。

二、多级缓存:从 500 QPS 到 3000+ QPS
#

500 → 3000+ QPS 自旋锁防击穿

技术派社区最核心的读操作是文章详情页,我们用 Caffeine + Redis 两级缓存来扛:

graph LR
    A[请求] --> B{Caffeine<br/>本地缓存}
    B -->|命中| C[返回]
    B -->|未命中| D{Redis<br/>分布式缓存}
    D -->|命中| E[回填本地缓存] --> C
    D -->|未命中| F{MySQL}
    F -->|命中| G[回填Redis] --> E
    F -->|未命中| H[返回空]
@Service
public class ArticleCacheService {

    @Autowired
    private Cache<Long, ArticleVO> localCache;

    public ArticleVO getArticle(Long articleId) {
        // Level 1: 本地缓存
        ArticleVO article = localCache.getIfPresent(articleId);
        if (article != null) return article;

        // Level 2: Redis 分布式缓存
        String cacheKey = "article:" + articleId;
        String cached = redisTemplate.opsForValue().get(cacheKey);

        if (cached != null) {
            article = JSON.parseObject(cached, ArticleVO.class);
            localCache.put(articleId, article);
            return article;
        }

        // Level 3: 数据库(带自旋锁防缓存击穿)
        article = loadWithSpinLock(articleId);
        if (article != null) {
            redisTemplate.opsForValue().set(cacheKey,
                JSON.toJSONString(article), 1, TimeUnit.HOURS);
            localCache.put(articleId, article);
        }
        return article;
    }

    /**
     * 自旋锁防缓存击穿
     * 热点 key 过期瞬间,大量请求同时打到数据库
     */
    private ArticleVO loadWithSpinLock(Long articleId) {
        String lockKey = "lock:article:" + articleId;
        int maxRetries = 3;
        int retryDelay = 50; // ms

        for (int i = 0; i < maxRetries; i++) {
            Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
            if (Boolean.TRUE.equals(locked)) {
                try {
                    return articleMapper.selectById(articleId);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            }
            try { Thread.sleep(retryDelay); } catch (Exception ignored) {}
        }
        // 降级:自旋失败直接查库
        return articleMapper.selectById(articleId);
    }
}

缓存策略总结

缓存层 过期时间 击中率 用途
Caffeine 5分钟 ~30% 抗热点,零网络开销
Redis 1小时 ~60% 分布式共享,集群一致
MySQL - ~10% 兜底,自旋锁保护

三、消息异步:RabbitMQ 解耦互动操作
#

评论 · 点赞 · 收藏

用户互动操作(评论、点赞、收藏、通知)如果同步处理,会严重拖慢主流程。用 RabbitMQ 做异步解耦:

sequenceDiagram
    participant 用户
    participant API
    participant RabbitMQ
    participant 消费者
    participant DB

    用户->>API: 点赞文章
    API->>RabbitMQ: 发送点赞事件
    API-->>用户: 200 OK(< 50ms)
    RabbitMQ->>消费者: 消费点赞事件
    消费者->>DB: 更新点赞计数
    消费者->>DB: 写入点赞记录
    消费者->>DB: 发送通知消息
// Producer:点赞即返回
@PostMapping("/like")
public Result<Void> likeArticle(@RequestParam Long articleId) {
    Long userId = getCurrentUserId();

    // 幂等检查
    if (likeService.hasLiked(userId, articleId)) {
        return Result.ok();
    }

    // 异步处理
    rabbitTemplate.convertAndSend("interaction.exchange",
        "interaction.like",
        new LikeEvent(userId, articleId));

    // 本地快速标记(最终一致性)
    likeService.markLikedLocal(userId, articleId);
    return Result.ok();
}

// Consumer:异步消费
@RabbitListener(queues = "interaction.like.queue")
public void handleLike(LikeEvent event) {
    // 1. 写入点赞记录
    likeRecordMapper.insert(event.toRecord());
    // 2. 更新文章点赞计数
    articleMapper.incrementLikeCount(event.getArticleId());
    // 3. 发送通知
    notificationService.sendLikeNotification(event);
}
💡 消息丢失怎么办?生产者确认模式 + 消费者手动 ACK + 死信队列兜底,三层保障。

四、作者白名单:Redis Set 轻量级权限控制
#

O(1) 鉴权

管理后台需要控制哪些用户有发布文章的权限,用 Redis Set 实现:

// 加入白名单
public void addToWhitelist(Long userId) {
    redisTemplate.opsForSet().add("author:whitelist", userId.toString());
}

// 检查权限(O(1))
public boolean isAuthor(Long userId) {
    return Boolean.TRUE.equals(
        redisTemplate.opsForSet().isMember("author:whitelist",
            userId.toString())
    );
}

// 获取全部作者
public Set<Long> getAllAuthors() {
    return redisTemplate.opsForSet().members("author:whitelist")
        .stream().map(Long::valueOf).collect(Collectors.toSet());
}

比数据库查询快,比硬编码灵活,万级白名单内存占用不到 1MB。

性能对比
#

指标 优化前 优化后
文章详情 QPS ~500 3000+
点赞响应时间 ~200ms(同步写库) < 50ms(异步)
热点文章 DB 查询 大量重复查询 自旋锁 + 缓存
登录方式 仅账密 账密 + 扫码

总结
#

技术派这个项目让我理解了一个道理:性能优化不是换个更贵的数据库,而是在每一层找到瓶颈然后用最合适的手段解决。Caffeine 抗热点、Redis 做共享、自旋锁防击穿、MQ 做异步——每个手段都不复杂,但组合起来效果是 6 倍的 QPS 提升。