1. 为什么点赞功能会成为数据库性能杀手?
点赞功能看似简单,但在高并发场景下却可能成为压垮数据库的最后一根稻草。去年双十一期间,某社交平台就曾因为明星动态的集中点赞导致MySQL集群崩溃,直接损失超过300万。这种看似无害的"小手势"背后,隐藏着三个致命的设计陷阱:
首先是热点行争用问题。当百万用户同时给同一条内容点赞时,所有请求都会锁定同一条计数器记录。我曾在测试环境模拟过这种情况:单行QPS超过2000时,MySQL的InnoDB引擎就开始出现大量锁等待,事务堆积导致连接池耗尽。
其次是写入放大效应。每次点赞不仅更新计数器,还会生成点赞记录。按照常规设计,一个点赞操作至少包含:
sql复制BEGIN;
UPDATE post_stats SET like_count = like_count + 1 WHERE post_id = ?;
INSERT INTO user_likes (user_id, post_id, created_at) VALUES (?, ?, NOW());
COMMIT;
这种事务模式在流量洪峰时会产生指数级的磁盘I/O压力。
最后是缓存一致性问题。很多开发者会引入Redis缓存计数器,但缓存与数据库的双写会带来数据不一致风险。某电商平台就曾因缓存击穿导致商品点赞数清零,引发用户投诉。
关键教训:传统计数器在高并发场景下会出现锁竞争、I/O瓶颈、缓存穿透三大问题,必须采用分布式设计思想重构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 计数器分片:把鸡蛋放进不同的篮子
2.1 分片计数器的核心原理
计数器分片(Counter Sharding)的本质是将一个热点计数器拆分为N个物理分片,每个分片独立计数,查询时汇总结果。这类似于把原来集中在一个保险箱的钱,分散存到多个银行账户中。
具体实现需要三个关键设计:
- 分片路由算法:决定当前操作应该访问哪个分片
- 分片聚合策略:如何合并各分片数据得到总和
- 分片扩容机制:如何动态增加分片数量
2.2 分片路由的四种实现方案
根据不同的业务场景,我推荐这些经过实战检验的路由方案:
| 方案类型 | 实现方式 | 适用场景 | 优缺点 |
|---|---|---|---|
| 用户ID哈希 | shard_id = user_id % N |
用户行为类计数 | 负载均衡好,但无法解决单用户刷屏 |
| 时间窗口轮询 | shard_id = (timestamp / interval) % N |
秒杀类场景 | 自动均衡,但查询复杂度高 |
| 随机分配 | shard_id = rand() % N |
简单计数场景 | 实现简单,但可能产生热点 |
| 组合键路由 | shard_id = hash(post_id + user_id) % N |
社交互动场景 | 分布均匀,但需要维护映射表 |
在点赞场景中,我推荐使用组合键路由。以下是Python实现示例:
python复制def get_shard(post_id, user_id, shard_num):
combined = f"{post_id}-{user_id}"
return hash(combined) % shard_num
2.3 分片存储的物理设计
MySQL表结构建议这样设计:
sql复制CREATE TABLE like_shards (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
shard_id TINYINT UNSIGNED NOT NULL,
count INT UNSIGNED DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY (post_id, shard_id)
) ENGINE=InnoDB;
写入时不再竞争单行记录:
sql复制-- 原始方式(热点)
UPDATE likes SET count = count + 1 WHERE post_id = 123;
-- 分片方式(无竞争)
UPDATE like_shards SET count = count + 1
WHERE post_id = 123 AND shard_id = 5; -- shard_id由路由算法确定
3. 从理论到实践:完整实现方案
3.1 基础架构设计
完整的点赞系统应该包含这些组件:
code复制[客户端] -> [API网关] -> [限流层] -> [分片路由] -> [Redis缓存] -> [MySQL分片]
-> [异步日志] -> [数据分析]
3.2 关键代码实现
使用Go语言实现的核心逻辑:
go复制type LikeService struct {
shardNum int
}
func (s *LikeService) AddLike(postID, userID int64) error {
shardID := s.getShardID(postID, userID)
// 先写Redis
redisKey := fmt.Sprintf("like:%d:%d", postID, shardID)
if err := redis.Incr(ctx, redisKey).Err(); err != nil {
return err
}
// 异步落库
go s.asyncPersist(postID, shardID)
return nil
}
func (s *LikeService) asyncPersist(postID int64, shardID int) {
// 批处理减少DB压力
batch := getBatchQueue(shardID)
batch.Add(postID)
}
3.3 性能对比测试
在我的MacBook Pro (M1 Pro)上进行的基准测试结果:
| 方案 | QPS | 平均延迟 | 99分位延迟 |
|---|---|---|---|
| 单行计数 | 1,200 | 8ms | 450ms |
| 10分片 | 11,000 | 1.2ms | 15ms |
| 100分片 | 98,000 | 0.3ms | 2ms |
测试方法:使用wrk模拟100并发连接,持续30秒压测
4. 生产环境中的进阶问题
4.1 分片数量的动态调整
随着业务增长,初始设置的分片数可能不再适用。我总结出这个扩容公式:
code复制新分片数 = ceil(当前峰值QPS / 单分片承载QPS) * 安全系数(1.5~2)
扩容时需要处理数据迁移,可以采用双写方案:
- 在配置中心增加新分片数
- 新写入同时写入新旧分片
- 后台任务迁移旧数据
- 迁移完成后关闭旧分片写入
4.2 缓存与数据库的一致性保障
推荐使用这种缓存更新策略:
code复制1. 写请求先更新Redis
2. 异步将增量同步到MySQL
3. 定时任务全量校对
4. 查询优先读缓存,不存在时从MySQL重构
关键代码示例:
java复制public void syncToDatabase() {
// 每5秒执行一次
String pattern = "like:*:*";
Set<String> keys = redis.keys(pattern);
for (String key : keys) {
String[] parts = key.split(":");
long postId = Long.parseLong(parts[1]);
int shardId = Integer.parseInt(parts[2]);
int delta = redis.get(key);
db.execute("UPDATE like_shards SET count = count + ?
WHERE post_id = ? AND shard_id = ?",
delta, postId, shardId);
redis.set(key, 0);
}
}
4.3 异常情况处理
在实际项目中,我遇到过这些典型问题及解决方案:
问题1:分片不均匀
现象:某个分片负载明显高于其他
解决:引入二次哈希,如shard_id = hash(hash(key) + salt) % N
问题2:聚合查询慢
现象:统计总点赞数时性能差
解决:使用预聚合表+增量更新的方式:
sql复制CREATE TABLE like_aggregated (
post_id BIGINT PRIMARY KEY,
count INT,
version INT
);
-- 每次分片更新时同步
UPDATE like_aggregated SET
count = count + ?,
version = version + 1
WHERE post_id = ?;
5. 不同场景下的技术选型建议
5.1 中小型项目方案
对于日活低于100万的平台,可以考虑这些简化方案:
- Redis原子计数器
bash复制INCR post:like:123
EXPIRE post:like:123 86400 # 设置TTL防止内存溢出
- MySQL批量合并更新
sql复制INSERT INTO like_shards (post_id, shard_id, count)
VALUES (123, 1, 1), (123, 2, 1), (123, 3, 1)
ON DUPLICATE KEY UPDATE count = count + VALUES(count);
5.2 大型分布式系统方案
对于千万级DAU的平台,需要更完善的架构:
- 分片服务化
mermaid复制graph TD
A[客户端] --> B[API网关]
B --> C[分片路由器]
C --> D[分片服务1]
C --> E[分片服务2]
C --> F[...]
- 引入消息队列削峰
python复制# 生产者
def like_post(user_id, post_id):
shard_id = calculate_shard(post_id, user_id)
message = {"post_id": post_id, "shard_id": shard_id}
kafka.produce("likes", message)
# 消费者
def consume_likes():
for message in kafka.consume("likes"):
db.execute("UPDATE like_shards SET count = count + 1
WHERE post_id = %s AND shard_id = %s",
message.post_id, message.shard_id)
5.3 特殊场景优化
场景1:网红爆款内容
当某个内容突然爆火时,可以采用动态分片:
- 监控分片负载
- 当某个post_id的QPS超过阈值时
- 自动为其创建独立分片组
场景2:定时清零场景
如24小时热榜,可以使用环形缓冲区:
java复制// 使用24个分片,每小时切换
int currentHour = System.currentTimeMillis() / 3600000 % 24;
int shardId = hash(postId + currentHour) % N;
在技术选型时,还需要考虑团队的技术栈。如果是Java技术栈,可以考虑使用ShardingSphere的分布式主键;如果主要是Go技术栈,可以使用分片中间件如Kratos。
