一个技术社区的诞生:从 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 提升。