大家好,我是晚安code。
关注功能高并发有多难扛?(2026 年 8 月实测,适用于 Redis 6.0+ / RocketMQ 4.9+)这次我把整套「Redis 缓冲 + ZSet 存关系 + Lua 保原子 + MQ 异步落库 + 令牌桶削峰」的方案完整写出来,你照着这套思路做高并发设计,能少走我踩过的弯路。
一、直接写库,关注接口为什么扛不住
先说结论:关注功能的高并发瓶颈,从来不在功能本身,而在你把高频写操作直接怼给了数据库。
关注这个动作拆开看,一次请求至少要干三件事:往关系表插入一条记录、给对方的粉丝数 +1、给自己维护的关注列表加一个成员。很多项目第一版就是这么干的——一个事务里同步写完这三步。用户量一上来,问题全暴露了:
- 关系表频繁 insert,索引不断膨胀,写入越来越慢;
- 高并发下「插入 + 计数」不是原子的,粉丝数可能对不上账;
- 数据库 IO 是有上限的,瞬时洪峰一来直接打穿。
我在见到的经典事故,就是「关注接口双写 Redis + DB」:请求来了先写 MySQL 再写 Redis,并发一上来,两边数据就开始打架——有人取关成功了,Redis 里关系还在;有人关注重复提交,计数翻倍。
说白了,关系数据本身不难存,难的是让数据库别被高频写压垮。所以整套方案的核心思路只有一个:把「高频写」前置到 Redis,用 MQ 把落库这件事切成异步,数据库只吃它扛得住的量。
二、Redis 缓冲层:把关系放进 ZSet
Redis ZSet(Sorted Set,有序集合):Redis 的一种数据结构,每个成员带一个 score 分值,成员按 score 自动排序。你可以理解为「带时间戳的收藏夹」——既能去重判断存没存过,又能按时间倒序翻页。
关注关系选 ZSet 而不是 Set,就一个原因:关注列表天然需要按时间排序。「最近关注了谁」是社交产品里最常见的查询,Set 只能存成员、排不了序,ZSet 用关注时间当 score,ZRANGE 一查就是按时间排好的列表。
我这里的存储模型是双向两份:
following:{用户ID} member=被关注人ID,score=关注时间戳
follower:{用户ID} member=粉丝ID,score=关注时间戳
关注时间做 score,取出来天然就是一条时间线,就像朋友圈按发布时间排。注意看这张概念图里的时间圆点——score 就是那条看不见的排序轨道:

这样设计的好处是:
- 插入高效:
ZADD复杂度 O(log N),时间戳做 score,天然有序; - 查询高效:
ZRANGE分页查关注/粉丝列表、ZSCORE判断是否已关注,都是毫秒级; - 取关干净:
ZREM一条命令删掉关系,两个 key 一起维护。

上面这张图就是完整链路:关注接口收到请求 → Lua 脚本原子更新 Redis → 发 MQ → 消费者令牌桶限流 → 异步落库。下面逐个环节拆。
三、Lua 脚本:高并发下 Redis 更新也要原子
Lua 脚本:Redis 内置的脚本语言,脚本会整段在 Redis 服务端原子执行——执行期间不会被其他命令插入。你可以理解为「Redis 内的一次性原子事务」。
很多人以为写了 Redis 就万事大吉,其实高并发下 Redis 的「检查 + 更新」也必须用 Lua 串成一步。看这段伪代码,是不是很眼熟:
-- 关注:判断 + 插入 + 计数,三条命令
local exists = redis.call('zscore', KEYS[1], ARGV[1])
if exists then return 1 end -- 已关注
redis.call('zadd', KEYS[1], ARGV[2], ARGV[1])
redis.call('hincrby', 'follow:count', ARGV[1], 1)
return 0
如果这三步分开执行,并发下就会出问题:两个请求同时判断「没关注」,然后各插一次、计数加两次。我在生产环境就踩过这个坑——上线当天关注数虚高,排查半天发现就是计数器被并发叠加了。
用 Lua 把「判断是否已关注 → 插入 ZSet → 更新计数」合并成一步,Redis 单线程保证整段脚本不会被打断,原子性天然解决。关注和取关各写一个脚本,eval 一次调用就完成,性能比拆分命令还好。
这个「合并成一步」就是 Lua 原子性的精髓——三条散落的命令,被串成一把钥匙上的三个齿,要么一起动,要么都不动:

四、发 MQ 异步落库:打 Tag 精确消费
MQ(Message Queue,消息队列):一个中转消息的中间件,生产者发消息、消费者异步取消息,两边解耦。你可以理解为「业务之间递纸条的收发室」——不是当面交接,而是放进信箱异步处理。
Redis 只是缓冲层,不是持久层。关系最终要落到数据库,但绝不能同步落。正确做法是:Redis 更新成功后,立即发一条消息到 MQ,接口直接返回「关注成功」,剩下的落库交给消费者慢慢做。
这里有个细节值得展开——发消息要打 Tag。以 RocketMQ 为例,同一个 Topic 下按业务场景打不同标签:
// 生产端:关注/取关打不同 Tag
Message msg = new Message(
"FOLLOW_TOPIC", // Topic
isFollow ? "FOLLOW" : "UNFOLLOW", // Tag 标签
userId + ":" + targetId // 业务数据
);
producer.send(msg);
消费者订阅时按 Tag 过滤,只处理自己关心的消息:
consumer.subscribe("FOLLOW_TOPIC", MessageSelector.byTag("FOLLOW || UNFOLLOW"));
为什么要打 Tag?三个理由:消费端能精确过滤,不用消费全部消息再业务层判断;方便分场景限流,关注和取关可以配置不同的处理速率;排查问题时定位快,一看 Tag 就知道这条消息是什么业务。
消息队列削峰的核心价值就在这:接口侧把瞬时洪峰甩给 MQ,MQ 像一个水库,把洪峰的水蓄住,再按下游能承受的流速放出去。这就是常说的削峰填谷。
你看这张图就明白了——上面是劈头盖脸的暴雨(洪峰),下面是稳稳的一条小溪(落库速率),中间的 MQ 就是那个水库闸门:

可能有人会问:Redis 更新成功了,MQ 消息发了,万一落库失败,数据不就对不上了?
放心,这正是异步方案必须配的兜底。消费者落库用「主键幂等」——
follow表用 (user_id, target_id) 建唯一索引,重复消息 insert 时冲突直接跳过;再加一层死信队列,重试几次还失败的进 DLQ 人工排查。Redis 作为读缓存,可以接受偶尔重建,数据库这一层最终一致就行。
五、消费端令牌桶:数据库只吃它吃得下的量
令牌桶(Token Bucket):一种限流算法,系统按固定速率往桶里放令牌,请求要先拿到令牌才能处理,桶满了就丢弃新令牌。你可以理解为「景区按固定频率放人进门的闸机」——人再多,进场的速率是定死的。
到消费端这一步,MQ 里已经堆了一大堆落库消息。如果消费者一股脑全拉出来写库,等于把洪峰从接口侧平移到了数据库侧,前面白干了。所以消费端必须限速,这就是令牌桶上场的地方。
我用的是 Redis 实现的分布式令牌桶:消费者每处理一条消息前,先 eval 一个 Lua 脚本拿令牌,拿到才继续,拿不到就稍等重试。核心脚本长这样:
local bucket = 'token:' .. KEYS[1] -- 桶名
local tokens = tonumber(redis.call('get', bucket) or '0')
local capacity = tonumber(ARGV[1]) -- 桶容量
if tokens >= 1 then
redis.call('decr', bucket)
return 1 -- 拿到令牌
end
return 0 -- 桶空,稍后再试
配合一个定时任务按固定速率补令牌,消费速率就被稳稳控住了。数据库的写入 TPS 从「不可控的洪峰」变成「恒定的 500」,压力直接可量化。
| 限流方案 | 是否支持突发 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 固定窗口 | 否,瞬间可能双倍 | 低 | 粗粒度限流 |
| 滑动窗口 | 部分 | 中 | 要求平滑 |
| 令牌桶 | 是,桶存突发量 | 中 | 削峰填谷、消费端限速 |
| 漏桶 | 否,恒定速率 | 低 | 强制匀速 |
对比下来,消费端削峰选令牌桶最合适:平时流量平稳时桶是满的、不排队;活动洪峰时桶蓄不住的部分直接丢弃或排队,数据库永远只看到固定速率。我实际调参时,桶容量按「数据库能承受的最大 TPS × 3」设置,既给了突发余量,又不至于把库打爆。
FAQ
可能有人会问:为什么关注功能不能先写数据库再删缓存?
顺序反过来会带来缓存与数据库的短暂不一致:删缓存和写库之间一旦有并发读,读到的就是旧缓存。异步落库 + Redis 兜底的方案里,Redis 是读的主来源,写库是后台补账,只要幂等键兜底,最终一致就够用。
六、总结:这套方案的核心思路
回顾一下整套链路:关注功能高并发设计,核心就一句话——Redis 当缓冲层挡住高频写,ZSet 存关系保排序,Lua 脚本把判断和更新合成原子一步,MQ 异步承接落库并打 Tag 分场景,消费端令牌桶把数据库压在恒定速率。五个环节各司其职,缺一个都容易在洪峰时出岔子。
以我踩过坑的经验,新手最容易漏的是第二环——以为 Redis 天然原子,结果计数器被并发打爆。原子性不会因为数据进了 Redis 就自动有,该用 Lua 还得用。
关注接口高并发设计自查清单:
- Redis 缓冲:关系写入全部前置 Redis;
- ZSet 双向存储:following / follower 各一份;
- Lua 原子:判断 + 插入 + 计数合并一个脚本;
- MQ 异步:发消息打 Tag,接口立即返回;
- 令牌桶限流:消费端限速落库,保护数据库。
这套方案同样适用于点赞、收藏、订阅这类「高频写关系」功能——把场景换一下,思路是通的。这套关注功能高并发方案在你那边落地时,建议先把量级估清楚:预估并发 1 万以内,简化到「Redis + 定时任务」也能扛;真正到了活动级洪峰,再上 MQ + 令牌桶不迟。如果想深入,推荐看 Redis 官方文档的 Lua 脚本章节,和 RocketMQ 的消息过滤文档(搜:Redis eval 文档、RocketMQ Tag 过滤)。
我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你在做关注、点赞这类高并发功能时,遇到过哪些「数据对不上账」的坑?