别再上来就堆 Redis!点赞系统从打挂数据库到 18w 写峰值,我把这套方案讲透了
高并发点赞关注怎么扛?从打挂数据库到 18w 写峰值,我把这套方案讲透了
爆款视频瞬间涌进几十万点赞,按钮卡 3 秒,数据库行锁排到爆——这场景我真遇到过。这篇文章把高并发点赞&关注的完整方案讲透:瓶颈定位、架构演进、生产级代码、踩坑经验,一条链路全包。适合正在做互动系统,或者准备面试的后端朋友。
一、别急着喊 Redis,先看清这是个什么业务
很多人一听"高并发点赞",脑子里就开始堆 Redis、MQ、分库分表。但真正好的方案一定是贴着业务设计的,不是背模板。动手之前,我习惯先对齐三件事:
- 量级:是"总量大"(百亿级点赞关系)还是"瞬时热点大"(单条内容 10w+ QPS)?这两个解法完全不同;
- 一致性:点赞数允许最终一致吗?差几秒、差几个数用户能接受吗?
- 读写比:读是不是远多于写?关注列表要不要分页排序?
对齐完,业务画像基本就出来了:
| 维度 | 特征 | 对设计的影响 |
|---|---|---|
| 写 | 点赞/取消、关注/取关都是轻量操作,热点事件下瞬时冲到数万 QPS | 不能直写 DB,必须异步削峰 |
| 读 | 列表页、详情页、个人主页都要展示计数和状态,读 QPS 是写的 5~10 倍 | 读才是大头,多级缓存扛量 |
| 一致性 | 自己的操作必须立即可见,别人的计数晚 1~2 秒完全能接受 | 典型的最终一致场景 |
| 幂等 | 同一用户不能重复点赞、重复涨粉 | 判重是全链路的命门 |
| 可靠性 | 不能丢、不能重 | 异步链路必须有兜底 |
我们实际压出来的指标:写峰值 5k~5w+ QPS,读峰值 10w+,目标是写 <100ms、读 <20ms。
点赞和关注看着像,其实有微妙差异,方案上得区别对待:
| 维度 | 点赞 | 关注 |
|---|---|---|
| 写特征 | 高频、可反复取消 | 高频、双向关系(关注/粉丝) |
| 数据结构 | 计数 String + 状态 Set/Bitmap | 双向 ZSet/Set |
| 查询特征 | 赞数要实时展示,关系可延迟 | 列表要按时间排序分页 |
| 一致性容忍 | 计数可秒级延迟,状态不能错 | 关系最终一致即可 |
还有个容易被忽略的用户心理:用户只关心自己有没有操作成功,没人关心自己是第 10086 个赞还是第 10087 个赞。所以"自己的操作秒级可见"必须保,全局计数可以稍微飘一飘。这个判断直接决定了后面整个架构的走向。
一句话总结这个业务:写多读更多、强依赖去重、容忍最终一致。
那最朴素的实现会死在哪?往下看。
二、瓶颈在哪:同步直写的三个死穴
最简单的实现:点赞 → 查库判断是否点过 → INSERT/DELETE 关系 → UPDATE 计数。这条链路在高并发下有三个死穴,随便中一个就躺。

死穴一:数据库热点行锁。 UPDATE t_like_count SET cnt = cnt + 1 WHERE content_id = 1001,InnoDB 行锁串行执行,所有事务在这一行上排队,TPS 撑死几千。单机 MySQL 写到 5k QPS 基本就到顶了,峰值下 IOPS 打满、连接池耗尽,大事务还会拖慢主从同步。我之前就踩过这个坑:V1 版本直写 DB,QPS 过 500 请求就开始堆积,一条热点内容直接把库锁炸。
死穴二:Redis 单 key 热点。 "那改 Redis INCR 总行了吧"——还是太天真。Redis Cluster 里一个 key 只会落在一个 slot 上,单 key 的 OPS 上限大概 8~10 万。爆款内容 10w QPS 全压在一个计数 key 上,对应分片的 CPU 直接打满,集群其他节点干看着帮忙都帮不上。
死穴三:幂等缺失。 客户端重试、用户狂点,"判断没点过 → 插入 → 计数 +1"三步分开执行,高并发下两个请求同时判断"没点过",计数直接虚高,跟超卖一个道理。
还有两个隐蔽的坑:
- 幂等校验开销大:每次操作前先查库判断状态,读压力被成倍放大;
- 读写互相阻塞:写操作占着行锁,连带读计数的请求也大量超时。
知道死在哪,就知道该往哪救了。
三、架构演进:从青铜到王者
我不喜欢一上来就甩最终方案,先讲演进——每一版解决上一版的主要矛盾,这也是面试里最加分的讲法。

- V1 青铜——直写 MySQL:查状态、写关系、更计数全在库里。热点行锁 + 全表扫描判重,前面说了,QPS 过 500 就开始堆。
- V2 白银——Redis 扛读:计数放
String,关系放Set,先写 Redis 再异步写库。明显顺滑了,但隐患不少:热门帖子的 Set 几十万元素有大 key 风险;异步消息一丢,Redis 挂了数据就没了;判重还是完全依赖 Redis。 - V3 黄金——Lua 原子 + MQ 异步落库:用 Lua 脚本把并发问题在 Redis 里消化,MQ 削峰、批量落库,唯一索引兜底幂等。到这版已经是生产级雏形。
- V4 王者——分桶破热点 + 多级降级:计数分桶、本地聚合、Caffeine 本地缓存、热点探测、完整降级预案。今天讲的就是这一版。
最终架构长这样:

各层职责一张表说清:
| 层 | 职责 | 关键技术 |
|---|---|---|
| 接入层 | 鉴权、限流、防刷 | 用户维度令牌桶、风控黑名单、前端防抖 |
| 服务层 | 无状态编排 | Caffeine、布隆过滤器、Lua 原子脚本、异步投递 |
| Redis 层 | 主存储 & 计数中心 | Set/Bitmap/ZSet、分桶计数 |
| MQ 层 | 削峰、重试、顺序 | 分区键 = targetId、Tag 分流 |
| DB 层 | 冷归档 & 对账基准 | 唯一索引幂等、批量 UPSERT、分库分表 |
整条链路可以这么记:Redis 是"承诺层",MQ 是"水库",消费者是"闸门",对账是"补账会计"。
写路径只有五步:
- 请求进来,网关限流 + 参数校验 + 布隆过滤器粗筛;
- Lua 脚本原子更新 Redis 的计数和关系状态;
- 立即给前端返回成功,用户侧秒级可见;
- 异步把操作消息投到 MQ;
- 消费端消费消息,幂等批量写入 MySQL。
这里有个经典取舍:先更缓存还是先写 DB?答案是先更缓存。 用户对自己操作的即时性要求最高,"我点了马上看到"是体验底线,持久化交给异步链路去保证。
设计总纲:缓存扛读、异步削写、原子幂等、最终一致。数据库不扛洪峰,只吃它吃得下的量。
框架立住了,下面逐层往下抠。先从最让人纠结的选型问题开始。
四、选型第一问:关系存 Set、Bitmap 还是 Hash?
"谁点过赞"这个关系集合,很多人想都不想就写 Set。Set 不是不对,但不是所有场景都最优。先看对比:
| 方案 | 结构 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
Set |
SADD like:{targetId} userId |
直观、存原始 ID、SADD 天然幂等 |
内存开销大 | 通用场景,首选 |
Bitmap |
SETBIT like:{targetId} userId 1 |
内存省约 32 倍,GETBIT 严格 O(1) |
要求 userId 连续或可控 | 用户 ID 自增可控 |
Hash |
HSET like:{targetId} userId ts |
可带时间戳等元信息 | 大 key 风险 | 需要记录操作时间 |
ZSet |
ZADD follow:{uid} ts targetId |
天然按时间排序分页 | 内存占用更大 | 关注列表要按时间序 |
算笔内存账:1000 万用户点赞,Set 存 1000 万个字符串大约 500MB+;Bitmap 只需要 1.25MB(1000 万 bit)。100 万用户也就 120KB 左右。差了两个数量级,这就是大 V 场景下 Set 和 Bitmap 的本质区别。
但千万别上来就用 Bitmap,先看你的 uid 分布。这是最容易踩的坑:
| 场景 | 存储选型 | 原因 |
|---|---|---|
| uid 连续、≤ 1 亿 | 直接 BITMAP |
10 亿 bit ≈ 125MB/对象,占用可控,BITCOUNT 极快 |
| uid 稀疏且很大(雪花 ID) | 分段 Bitmap 或回退 Set |
Bitmap 按最大 uid 分配空间,直接撑爆 |
| 超大对象 + 稀疏 | 分段 RoaringBitmap |
压缩稀疏位图,几 KB 装十万级 |
如果 userId 是雪花算法生成的 long,Bitmap 会按最大 ID 分配位图,空间爆炸。这时候要么分段,要么上 RoaringBitmap,要么老老实实回 Set。分段路由的写法:
/** 分段位图路由:高位分段 + 段内偏移,避免稀疏 uid 撑爆内存 */
public final class BitmapRouter {
private static final long SEG = 1L << 28; // 每段 2.68 亿个位 ≈ 32MB
public static String segKey(String biz, long targetId, long uid) {
return "like:bit:" + biz + ":" + targetId + ":" + (uid / SEG);
}
public static long offset(long uid) {
return uid % SEG;
}
}
计数就简单了:String 结构 + INCR/DECR 原子增减。有个细节提醒:关注数、粉丝数要放独立计数 key,别偷懒用 SCARD。SCARD 本身是 O(1),但大 key 的读写会阻塞 Redis 单线程;而且粉丝列表一旦分片,SCARD 只能拿到单片数量。所以用 HINCRBY follow:cnt:fans:{uid} 1 单独维护总数,读总数 O(1) 且不受分片影响。
结构选好了,还剩第一个并发硬骨头:原子性。
五、原子性这件事:一个 Lua 脚本消化所有并发
先看坑。"判断 → 写入 → 计数"三条命令分开执行,高并发下两个请求同时判断"没点过"、同时 SADD、同时 INCR,计数直接翻倍。很多人以为"进了 Redis 就自动原子",这是天大的误会——原子的是单条命令,不是你的业务组合。
为什么选 Lua:
- Redis 单线程执行 Lua,脚本整体不被打断,天然原子;
- "判重 + 写入 + 计数 + 返回"四步合并成 1 次网络往返。同步链路里最贵的是网络 IO,不是 Redis 计算;
- 比 Java 层加锁性能高得多,并发问题在缓存层就消化掉,DB 感知不到任何冲突。
为什么不用 MULTI/EXEC? 它只是排队执行,做不了"读了结果再决定下一步"的分支逻辑——你拿不到中间命令的返回值,幂等判断就得两次 RT。Lua 天然支持条件分支 + 一次 RT,没理由不选。
通用版脚本(Set 存关系,点赞/取消通用):
-- operate.lua 点赞/取消点赞原子脚本
-- KEYS[1]: 关系集合key KEYS[2]: 计数key
-- ARGV[1]: 操作类型 1=点赞 0=取消
-- ARGV[2]: userId
-- 返回值: 1=操作真实生效 0=重复操作(幂等) -1=参数错误
local relationKey = KEYS[1]
local countKey = KEYS[2]
local op = tonumber(ARGV[1])
local userId = ARGV[2]
if op == 1 then
if redis.call('SISMEMBER', relationKey, userId) == 1 then
return 0 -- 已点赞,幂等拦截
end
redis.call('SADD', relationKey, userId)
redis.call('INCR', countKey)
redis.call('EXPIRE', relationKey, 604800) -- 冷内容 7 天过期
redis.call('EXPIRE', countKey, 604800)
return 1
elseif op == 0 then
if redis.call('SISMEMBER', relationKey, userId) == 0 then
return 0 -- 没点过,拒绝无意义取消,防负数
end
redis.call('SREM', relationKey, userId)
redis.call('DECR', countKey)
return 1
else
return -1
end
几个细节值得品:
- 返回值设计是灵魂。
1真实生效、0重复请求、-1参数错误,业务层拿到结果直接走分支,不用再回 Redis 查一次,省一次 RTT; - 防负数:取消之前先判存在,"点赞 +1、取消 -2"这种负数事故从根上不存在;
- 冷数据 7 天过期,过期后由 DB 兜底重建,不占无效内存(如果业务对计数敏感,也可以不设过期,交给对账兜底,两种流派都行);
- 点赞和取消走同一段脚本,逻辑对称,好测好维护。
Java 侧调用,注意三个点:布隆前置、真实生效才发 MQ、MQ 失败有兜底:
@Service
public class LikeServiceImpl implements LikeService {
@Resource
private StringRedisTemplate redisTemplate;
@Resource
private RocketMQTemplate mqTemplate;
private static final DefaultRedisScript OPERATE_SCRIPT =
new DefaultRedisScript<>(new ClassPathResource("operate.lua"), Long.class);
private static final String MQ_RETRY_KEY = "interact:mq:retry";
public Result like(Long targetId, Long userId, boolean like) {
// ① 布隆过滤器前置:挡掉 90% 内容/用户不存在的无效请求
if (!targetBloomFilter.mightContain(targetId)
|| !userBloomFilter.mightContain(userId)) {
return Result.fail("操作对象不存在");
}
// ② Lua 原子更新:并发在 Redis 单线程里消化
Long effect = redisTemplate.execute(OPERATE_SCRIPT,
List.of(buildRelationKey(targetId), buildCountKey(targetId)),
like ? "1" : "0", String.valueOf(userId));
if (effect == null || effect == 0) {
return Result.fail("请勿重复操作"); // 幂等命中,快速返回
}
// ③ 只有真实生效的操作才进 MQ,落库流量天然减半
LikeEvent msg = LikeEvent.of(targetId, userId, like);
try {
mqTemplate.sendOneWay("social_interact_topic",
MessageBuilder.withPayload(msg).build());
} catch (Exception e) {
// 兜底:发送失败进 Redis 重试队列,定时任务 10s 扫一次重发
redisTemplate.opsForList().leftPush(MQ_RETRY_KEY, JSON.toJSONString(msg));
log.error("MQ 发送失败,进重试队列, targetId:{}", targetId, e);
}
return Result.ok(); // 立即返回,用户侧秒级可见
}
}
技术亮点:①
sendOneWay单向发送性能最高,不等 Broker 确认;② MQ 发送失败不回滚缓存,而是进重试队列——用户体验和最终不丢都要;③ 只有真实生效的操作才投递消息,持久化流量天然砍半。
面试里如果被追问"Redis 凭什么保证原子性",两个关键词就够了:单线程 + Lua 整体执行不被打断。
原子性是解决了,但顶流明星官宣,几亿人同时砸同一个 key,怎么办?
六、热点 Key 治理:把集中的写打散
Redis Cluster 能把数据分片,但拆不了单个 key。爆款内容的计数 key 注定被打爆,三板斧,由浅入深。
6.1 第一斧:计数分桶
把 like:count:{targetId} 拆成 like:count:{targetId}:{0~15},写的时候随机(或按 userId 哈希)选片,读的时候求和:
private static final int SHARD_NUM = 16; // 分片数按量级选:8、16、100 都可以
// 写:随机路由到分片,把单 key 压力摊到 N 个 key
public void incrSharded(Long contentId) {
int shard = ThreadLocalRandom.current().nextInt(SHARD_NUM);
redis.opsForValue().increment("like:count:" + contentId + ":" + shard);
}
// 读:MGET 一次拿回求和(配合本地缓存,3~5 秒才回源一次)
public long getLikeCount(Long contentId) {
return caffeine.get(contentId, id -> {
long sum = 0;
for (int i = 0; i < SHARD_NUM; i++) {
String v = redis.opsForValue().get("like:count:" + id + ":" + i);
sum += v == null ? 0 : Long.parseLong(v);
}
return sum;
});
}
分片数是经验值:16 片就是写压力降 16 倍;读变成 16 个 key 的 MGET——但读有本地缓存兜着,几乎不回 Redis,这个代价基本白嫖。
更彻底的做法是直接在 Lua 里完成分桶,一次 RT 搞定:
-- SETBIT 的返回值就是幂等标记:旧值为 1 说明早就点过,不需要额外判断
local old = redis.call('SETBIT', bitmapKey, uid, 1)
if old == 1 then
return {0, sumBuckets(cntKey)} -- 重复点赞:不动计数,直接返回当前总数
end
-- 分桶计数:把「一个热点 key」拆成 100 个 field,写压力均摊
local bucket = uid % 100
redis.call('HINCRBY', cntKey, 'b' .. bucket, 1)
技术亮点:
SETBIT的返回值就是旧值,直接拿来当幂等标记,比SISMEMBER + SADD两步省一次判断;计数从"一个热点 key"变成"一个 Hash 的 100 个 field",读总数时HGETALL拿 100 个 field(不到 1KB),Lua 内累加微秒级。
6.2 第二斧:本地聚合,100 次请求合成 1 次
分桶是把压力摊开,但每个请求还是要打一次 Redis。更狠的做法:应用层先攒一手。

横向拆(分片)+ 纵向合(聚合),双管齐下:
/**
* 热点计数写入器:本地聚合 + 分片打散
* 亮点:LongAdder 无锁累加、定时 flush、失败回滚
*/
@Component
public class HotCounterWriter {
private static final int SHARD = 16;
private final ConcurrentHashMap buffer = new ConcurrentHashMap<>(1024);
@Resource
private StringRedisTemplate redis;
@Scheduled(fixedRate = 1000)
public void flush() {
for (Map.Entry e : buffer.entrySet()) {
long delta = e.getValue().sumThenReset(); // 原子取走并清零
if (delta == 0) continue;
try {
redis.opsForValue().increment(e.getKey(), delta); // 一次 INCRBY 合并
} catch (Exception ex) {
e.getValue().add(delta); // flush 失败回滚 buffer,不丢数据
log.error("flush counter fail, key={}", e.getKey(), ex);
}
}
}
/** 写路径:只做本地累加,纳秒级 */
public void incr(long objId, int delta) {
int shard = ThreadLocalRandom.current().nextInt(SHARD);
buffer.computeIfAbsent("like_cnt:" + objId + ":" + shard,
k -> new LongAdder()).add(delta);
}
}
两个面试加分点:
LongAdder用分段 CAS 避免伪共享,sumThenReset()保证"取走 + 清零"的原子性;- flush 失败要回滚 buffer——很多人的方案在这里悄悄丢数据,这一行就是分水岭。
加了这一层,Redis 网络调用量从每请求 1 次降到约 1/50。
6.3 第三斧:Caffeine 本地缓存扛读
热点计数读多写少,应用层加一级短 TTL 缓存,绝大部分读请求不出 JVM:
private final Cache cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(3, TimeUnit.SECONDS) // 容忍 3 秒不一致,拦掉 90%+ 重复读
.build();
6.4 热点 Key 怎么发现?
别等告警响了才知道有热点,三条路:
- Redis 自带
redis-cli --hotkeys、MONITOR抽样(生产环境慎用MONITOR); - 自研滑动窗口统计:本地每 1s 上报 top N 到聚合服务;
- 业界方案:JD-hotkey、Sentinel 集群流控。
发现之后立即本地缓存 + 分桶 + 限流三板斧。极端情况下,还可以把顶流热点内容单独迁到独立热点集群,别让一个热 key 打爆普通实例;写侧再极端一点,用 ConcurrentLinkedQueue 暂存、每 50ms 批量发 Redis,用少量延迟换吞吐。
(这段比较硬核,觉得有用的话先点个关注不迷路,下面还有更实战的。)
写端防住了,几十万 QPS 的数据怎么温和地落进 MySQL?
七、落库削峰:光有 MQ 不够,还得控流速
总原则:Redis 操作成功后立即返回用户,不等写库,MQ 异步消费落库。但这里有五个细节,少一个,洪峰照样出事。
7.1 生产端:Tag 分流 + 分区键保序
// 点赞/取消打不同 Tag:可以单独限流、单独排查、单独重试
Message msg = new Message("LIKE_TOPIC",
isLike ? "LIKE" : "UNLIKE",
userId + ":" + postId);
producer.send(msg);
// Kafka 场景:分区键用 postId,保证同一帖子的消息有序
kafkaTemplate.send("like-events", postId.toString(), event);
消息体只带"谁对什么做了什么",别塞冗余字段。分区键 = targetId 还有个好处:同一对象的消息有序,消费端可以按对象维度安全地批量聚合。
7.2 消费端:攒批 + 合并,把 10w QPS 压成 600 TPS
千万别逐条落库,否则等于把洪峰从接口侧平移到数据库侧,前面全白干。消费端做三层压缩:
第一层:攒批。 攒够 100 条或 1 秒超时(激进点 50ms 兜底 flush),批量写。
第二层:最终态合并。 用户反复点赞又取消("抖手党"),一个窗口内按 (userId, targetId) 聚合,只落最终状态,DB 写量再砍一半。
第三层:计数合并。 计数是聚合数据,天然适合批量合并——100 条消息在消费端合成一次 INCRBY,DB 写入次数从 100 变 1。按 contentId 分组求和,一条 SQL 批量 UPSERT。
三层揉在一个消费者里:
/**
* 点赞落库消费者:攒批 + 最终态合并 + 幂等落库
* 亮点:MQ 线程只入队不阻塞;失败进 WAL,绝不静默丢
*/
@Component
public class LikePersistConsumer {
private final BlockingQueue buffer = new LinkedBlockingQueue<>(200_000);
@RocketMQMessageListener(topic = "like_topic", consumerGroup = "like_consumer_group")
public void onMessage(LikeMsg msg) { // 只做入队,绝不阻塞 MQ 线程
if (!buffer.offer(msg)) { // 队列满 = 压力过载信号
Metrics.counter("like_buffer_full").increment();
directWrite(msg); // 兜底:同步单条写,保证不丢
}
}
@PostConstruct
void startFlushLoop() {
Thread t = new Thread(this::flushLoop, "like-flush");
t.setDaemon(true);
t.start();
}
private void flushLoop() {
List batch = new ArrayList<>(1000);
while (!Thread.currentThread().isInterrupted()) {
batch.clear();
buffer.drainTo(batch, 1000); // 攒批,最大 1000 条
if (batch.isEmpty()) {
LockSupport.parkNanos(50_000_000L); // 50ms 兜底 flush,控制延迟
continue;
}
try {
persist(mergeFinalState(batch));
} catch (Exception e) {
RetryQueue.push(batch, e); // 失败进重试队列/本地磁盘 WAL
}
}
}
/** 最终态合并:同一用户同一目标反复操作,只落最后一条 */
private List mergeFinalState(List batch) {
Map merged = new LinkedHashMap<>();
for (LikeMsg m : batch) {
merged.put(m.getUserId() + ":" + m.getTargetId(), m); // 后到覆盖先到
}
return new ArrayList<>(merged.values());
}
/** 单条 SQL 批量提交:唯一索引保证幂等,重复写无副作用 */
private void persist(List batch) {
jdbc.batchUpdate(
"INSERT INTO t_like(user_id, target_id, status, update_time) " +
"VALUES (?,?,?,NOW()) " +
"ON DUPLICATE KEY UPDATE status = VALUES(status), update_time = NOW()",
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
LikeMsg m = batch.get(i);
ps.setLong(1, m.getUserId());
ps.setLong(2, m.getTargetId());
ps.setInt(3, m.getStatus());
}
@Override
public int getBatchSize() { return batch.size(); }
});
// 计数不在这里做热点行 +1:以 Redis 为准,分钟级快照回写 + 对账校正
}
}
7.3 幂等:关键不在消费端"判重",在唯一索引
很多人会在消费端写一大段"先查后插"的判重逻辑。其实给 (user_id, target_id) 建唯一索引,配合 ON DUPLICATE KEY UPDATE 或 INSERT IGNORE 更干净——重复消息冲突直接跳过,MQ 怎么重投都没有副作用。
-- 关系表:唯一索引是幂等的最后一道防线
CREATE TABLE t_like (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
status TINYINT NOT NULL, -- 1 赞 / 0 取消,软删除避免物理删的写放大
update_time DATETIME,
UNIQUE KEY uk_user_target (user_id, target_id)
) ENGINE=InnoDB;
-- 计数表与明细表分离,避免行锁互相影响
INSERT INTO t_like_count (content_id, like_count) VALUES (?, ?)
ON DUPLICATE KEY UPDATE like_count = like_count + VALUES(like_count);
-- 关系写入:撞唯一索引直接跳过
INSERT IGNORE INTO t_like (user_id, target_id, status) VALUES (?, ?, 1);
如果担心重复消息打 DB,可以再加一层:消费前用 Redis SETNX 打消费标记(24 小时过期),重复消息直接 ACK,比查库性能高一个量级;批内还可以按消息 ID 先去一遍重。双层幂等下来,重复数据出现的概率约等于零。
库表层面还有几个配置项:点赞关系表按 targetId 分库分表,关注表按 userId 分,单表控制在千万级以内;关注是双向关系,DB 侧一般双向表双写(t_follow(from, to) + t_fans(to, from)),各自按自己的查询维度分片——这个第九章细讲。
重要结论:DB 主表不做
cnt = cnt + 1的热点行更新。计数以 Redis 为准,按分钟级快照回写,对账任务再校正。分片表能解决总 TPS,但解决不了单条内容的行热点——把"自增"从 DB 里挪出去,Redis 才是正确的计数载体。
7.4 令牌桶控落库流速:最容易被忽略的一环
MQ 把洪峰蓄住了,但如果消费者一股脑全拉出来写库,洪峰只是换了个地方砸。用 Redis 实现分布式令牌桶,消费前先拿令牌:
-- KEYS[1] = token:bucket:{name}
local tokens = tonumber(redis.call('GET', KEYS[1]) or '0')
if tokens >= 1 then
redis.call('DECR', KEYS[1])
return 1
end
return 0
配合定时任务按固定速率补令牌,数据库写入 TPS 从"不可控的洪峰"变成"恒定的可预期值"。这一步不做,前面的削峰等于白削。
7.5 消息可靠性:不丢不重
- 生产端:发送失败进 Redis 重试队列 / 本地消息表,定时任务扫描重发;
- 消费端:手动 ACK + 重试,重试超过 N 次进死信队列或落本地磁盘 WAL,由补偿任务重放;
- 顺序:分区键 = targetId,同对象有序,重试不产生副作用。
写链路到此闭环。但读端的体验,同样有不少活儿要干。
八、读端也别糊弄:批量查询与缓存三防
列表页要展示 20 条内容,每条都得拿"点赞数 + 我是否赞过"。最朴素的写法是循环查 Redis——20 次网络 IO,RT 直接 50ms 起步。
8.1 Pipeline 批量查,N 次 IO 变 1 次
// 别在 for 循环里调 Redis!Pipeline 打包命令,一次网络往返搞定
public List batchCheckLiked(Long userId, List postIds) {
List 原文地址: http://www.cveoy.top/t/topic/qHL9 著作权归作者所有。请勿转载和采集!