高并发计数系统设计
计数业务特性分析
计数功能在互联网应用中随处可见:文章阅读数、视频播放量、商品收藏数、社交点赞数等。这类业务看似简单,但在高并发场景下却面临着严峻的技术挑战。
业务复杂度分级
不同业务场景对计数系统的要求差异很大:
graph TD
A[计数业务类型] --> B[低并发场景]
A --> C[中等并发场景]
A --> D[高并发场景]
B --> B1[博客阅读数<br/>商品浏览量]
B1 --> B2[直接数据库累加]
C --> C1[电商商品收藏<br/>普通视频点赞]
C1 --> C2[Redis + 异步落库]
D --> D1[热门直播点赞<br/>明星微博互动]
D1 --> D2[多级缓存 + 批量合并<br/>+ 分片扣减]
style A fill:#4A90E2,color:#fff,rx:10,ry:10
style B fill:#27AE60,color:#fff,rx:10,ry:10
style C fill:#E67E22,color:#fff,rx:10,ry:10
style D fill:#E74C3C,color:#fff,rx:10,ry:10核心技术难点
高并发计数的本质是热点数据更新问题。当百万用户同时对同一个内容进行点赞操作时,所有请求都指向同一条数据记录,这就形成了典型的热点写入。
主要挑战:
- 数据库锁竞争:大量并发UPDATE语句对同一行加锁,导致请求排队
- 连接池耗尽:等待锁释放的请求占用数据库连接,可能导致连接池枯竭
- 数据准确性:高并发下如何保证计数的最终准确性
- 系统稳定性:避免计数服务异常影响核心业务
基础方案:数据库直接累加
对于日均访问量在万级以下的低并发场景,直接使用数据库累加是最简单有效的方案。
-- 文章阅读数累加
UPDATE article_stats
SET view_count = view_count + 1
WHERE article_id = #{articleId};数据库通过行级锁保证了并发更新的安全性,实现简单且数据可靠。
适用边界:
- QPS < 100 的一般业务场景
- 对响应延迟要求不高
- 数据一致性要求高于性能
进阶方案:Redis缓存层
当并发量提升到每秒数百甚至上千时,数据库已经无法承受压力。此时需要引入Redis作为缓存层。
基本架构
graph LR
A[用户请求] --> B[应用服务]
B --> C{Redis计数}
C --> D[INCR操作]
D --> E[返回结果]
F[定时任务] --> G[读取Redis计数]
G --> H[批量写入数据库]
I[查询请求] --> J[先查Redis]
J --> K{是否存在}
K -->|是| L[返回Redis值]
K -->|否| M[查询数据库]
M --> N[回写Redis]
N --> L
style A fill:#4A90E2,color:#fff,rx:10,ry:10
style C fill:#E67E22,color:#fff,rx:10,ry:10
style H fill:#27AE60,color:#fff,rx:10,ry:10实现要点
利用Redis的INCR命令实现原子计数:
@Service
public class ArticleCounterService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String COUNT_KEY_PREFIX = "article:view:";
/**
* 增加阅读计数
*/
public Long incrementViewCount(String articleId) {
String key = COUNT_KEY_PREFIX + articleId;
return redisTemplate.opsForValue().increment(key);
}
/**
* 获取阅读计数
*/
public Long getViewCount(String articleId) {
String key = COUNT_KEY_PREFIX + articleId;
String value = redisTemplate.opsForValue().get(key);
if (value != null) {
return Long.parseLong(value);
}
// 缓存未命中,从数据库加载
Long dbCount = articleStatsMapper.getViewCount(articleId);
redisTemplate.opsForValue().set(key, String.valueOf(dbCount));
return dbCount;
}
}数据同步策略
Redis中的计数数据需要定期同步到数据库进行持久化:
@Scheduled(fixedRate = 60000) // 每分钟执行
public void syncCountToDatabase() {
// 获取所有需要同步的计数key
Set<String> keys = redisTemplate.keys(COUNT_KEY_PREFIX + "*");
for (String key : keys) {
String articleId = key.replace(COUNT_KEY_PREFIX, "");
String countStr = redisTemplate.opsForValue().get(key);
if (countStr != null) {
Long count = Long.parseLong(countStr);
// 更新到数据库
articleStatsMapper.updateViewCount(articleId, count);
}
}
}高级方案:批量合并技术
对于超高并发场景(如热门直播间点赞),即使Redis也可能面临压力。此时可以在客户端和服务端同时引入批量合并机制。
客户端批量合并
用户在短时间内可能多次点击点赞按钮,前端可以将这些点击合并后再发送到服务端:
sequenceDiagram
participant U as 用户
participant F as 前端
participant S as 服务端
U->>F: 点击点赞
F->>F: 本地计数+1
U->>F: 再次点击
F->>F: 本地计数+1
U->>F: 再次点击
F->>F: 本地计数+1
Note over F: 累积3秒或达到阈值
F->>S: 批量提交(+3)
S-->>F: 确认前端伪代码示例:
class LikeCounter {
constructor(roomId) {
this.roomId = roomId;
this.pendingCount = 0;
this.timer = null;
}
// 用户点击点赞
onLike() {
this.pendingCount++;
this.updateUI();
// 防抖:3秒后或累积100次时提交
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), 3000);
}
if (this.pendingCount >= 100) {
this.flush();
}
}
// 批量提交到服务端
async flush() {
if (this.pendingCount === 0) return;
const count = this.pendingCount;
this.pendingCount = 0;
clearTimeout(this.timer);
this.timer = null;
await api.batchLike(this.roomId, count);
}
}服务端批量合并
服务端同样可以将短时间内的请求合并处理:
@Service
public class BatchLikeService {
// 使用ConcurrentHashMap暂存待提交的点赞数
private ConcurrentHashMap<String, AtomicLong> pendingLikes = new ConcurrentHashMap<>();
/**
* 接收点赞请求,先暂存
*/
public void addLike(String roomId, int count) {
pendingLikes.computeIfAbsent(roomId, k -> new AtomicLong(0))
.addAndGet(count);
}
/**
* 定时批量提交到Redis
*/
@Scheduled(fixedRate = 1000) // 每秒执行
public void flushToRedis() {
// 获取并清空待处理数据
Map<String, AtomicLong> toProcess = new HashMap<>(pendingLikes);
pendingLikes.clear();
// 批量更新到Redis
toProcess.forEach((roomId, count) -> {
if (count.get() > 0) {
redisTemplate.opsForValue()
.increment("room:like:" + roomId, count.get());
}
});
}
}极致方案:分片计数
对于微博热搜、顶流直播这种极端场景,单个Redis Key也可能成为瓶颈。此时需要采用分片策略。
分片架构设计
graph TD
A[点赞请求] --> B{分片路由}
B --> C1[分片1: room:like:001:1]
B --> C2[分片2: room:like:001:2]
B --> C3[分片3: room:like:001:3]
B --> C4[分片N: room:like:001:N]
style A fill:#4A90E2,color:#fff,rx:10,ry:10
style B fill:#9B59B6,color:#fff,rx:10,ry:10
style C1 fill:#27AE60,color:#fff,rx:10,ry:10
style C2 fill:#27AE60,color:#fff,rx:10,ry:10
style C3 fill:#27AE60,color:#fff,rx:10,ry:10
style C4 fill:#27AE60,color:#fff,rx:10,ry:10graph TD
D[查询请求] --> E[聚合所有分片]
E --> F[分片1的值]
E --> G[分片2的值]
E --> H[分片3的值]
E --> I[分片N的值]
F --> J[求和返回]
G --> J
H --> J
I --> J
style J fill:#E67E22,color:#fff,rx:10,ry:10分片实现
@Service
public class ShardedLikeService {
private static final int SHARD_COUNT = 10;
private static final String KEY_PATTERN = "room:like:%s:%d";
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 增加点赞数(随机选择分片)
*/
public void addLike(String roomId, int count) {
// 随机选择一个分片
int shardIndex = ThreadLocalRandom.current().nextInt(SHARD_COUNT);
String key = String.format(KEY_PATTERN, roomId, shardIndex);
redisTemplate.opsForValue().increment(key, count);
}
/**
* 获取总点赞数(聚合所有分片)
*/
public Long getTotalLikes(String roomId) {
long total = 0;
// 构建所有分片的key
List<String> keys = new ArrayList<>();
for (int i = 0; i < SHARD_COUNT; i++) {
keys.add(String.format(KEY_PATTERN, roomId, i));
}
// 批量获取所有分片的值
List<String> values = redisTemplate.opsForValue().multiGet(keys);
for (String value : values) {
if (value != null) {
total += Long.parseLong(value);
}
}
return total;
}
}分片策略优化
简单随机分片可能导致分片不均匀,可以采用更智能的策略:
- 轮询分片:按请求序号轮询选择分片,保证绝对均匀
- 一致性哈希:根据用户ID哈希选择分片,便于统计用户点赞记录
- 动态分片:根据流量动态调整分片数量
完整架构:多层协同
将上述所有技术整合,构建一个高性能计数系统:
写入流程
客户端与应用层:用户点击在本地累积,批量提交到服务端暂存后定时刷入缓存。
graph LR
A[用户点击] --> B[本地累积]
B --> C[批量提交]
C --> D[服务端接收]
D --> E[内存暂存]
E --> F[定时批量提交]
style A fill:#4A90E2,color:#fff,rx:10,ry:10
style C fill:#9B59B6,color:#fff,rx:10,ry:10
style F fill:#27AE60,color:#fff,rx:10,ry:10缓存层分片:请求通过分片路由分散到多个Redis节点。
graph TD
A[批量请求] --> B{分片路由}
B --> C[Redis分片1]
B --> D[Redis分片2]
B --> E[Redis分片3]
B --> F[Redis分片N]
style A fill:#4A90E2,color:#fff,rx:10,ry:10
style B fill:#9B59B6,color:#fff,rx:10,ry:10
style C fill:#E67E22,color:#fff,rx:10,ry:10
style D fill:#E67E22,color:#fff,rx:10,ry:10
style E fill:#E67E22,color:#fff,rx:10,ry:10
style F fill:#E67E22,color:#fff,rx:10,ry:10持久化流程
定时任务聚合各分片数据,批量写入数据库进行持久化存储。
graph LR
A[定时任务] --> B[聚合分片数据]
B --> C[批量写入]
C --> D[(MySQL)]
style A fill:#4A90E2,color:#fff,rx:10,ry:10
style B fill:#9B59B6,color:#fff,rx:10,ry:10
style D fill:#27AE60,color:#fff,rx:10,ry:10查询流程
优先查询缓存,未命中时查询数据库并回写缓存。
graph LR
A[查询请求] --> B{缓存命中?}
B -->|是| C[聚合分片返回]
B -->|否| D[查询数据库]
D --> E[回写缓存]
E --> C
style A fill:#4A90E2,color:#fff,rx:10,ry:10
style B fill:#9B59B6,color:#fff,rx:10,ry:10
style C fill:#27AE60,color:#fff,rx:10,ry:10
style D fill:#E67E22,color:#fff,rx:10,ry:10数据一致性考量
对精确度的容忍
计数业务通常不需要绝对精确。以直播点赞为例,用户看到的是"128.5万"而非"1285647",允许存在一定误差:
- 客户端批量合并可能因页面关闭而丢失
- Redis数据落库存在延迟
- 分片聚合存在短暂不一致
这些在业务层面都是可以接受的,只要保证最终一致性即可。
异常恢复机制
需要考虑Redis故障时的应对策略:
public Long getLikeCount(String roomId) {
try {
// 优先从Redis获取
return getTotalFromRedis(roomId);
} catch (Exception e) {
log.warn("Redis获取失败,降级到数据库", e);
// 降级到数据库
return getLikeCountFromDB(roomId);
}
}防止数据丢失
为避免Redis故障导致数据丢失,可以采用以下策略:
- Redis持久化:开启AOF持久化,每秒刷盘
- 流水记录:每次写入同时记录到ZSet,便于对账
- 双写机制:关键数据同时写入Redis和消息队列
性能对比分析
不同方案在各指标上的表现:
| 方案 | QPS承载能力 | 实时性 | 准确性 | 实现复杂度 |
|---|---|---|---|---|
| 数据库直接累加 | 百级 | 高 | 精确 | 低 |
| Redis单Key | 万级 | 高 | 准确 | 低 |
| 批量合并 | 十万级 | 中 | 准确 | 中 |
| 分片计数 | 百万级 | 中 | 准确 | 高 |
| 多层协同 | 千万级 | 中 | 近似 | 高 |
选择合适的方案需要根据实际业务需求进行权衡,避免过度设计。
更新: 2025-12-04 17:42:34
原文: https://www.yuque.com/u22210564/zoxfmt/doc-30-03