一次技术分享结束,有位同学问我:“点赞接口到底难在哪?不就是一条 update 语句吗?”我当时笑了笑。这个问题恰好说明,百万级点赞系统是高并发场景里性价比最高的练兵场——业务规则简单,却能把缓存、消息队列、分库分表、幂等防重、热点防护这些后端硬功夫全部串起来。这篇文章就把我自己设计过的一版完整方案拆开讲:从流量预估到存储建模,从写入链路到缓存兜底,最后落到压测参数和故障排查,适合正在规划高流量业务的后端同学,也适合准备系统设计面试的开发者。
1. 百万级点赞场景解析与核心挑战
1.1 一个“数字+1”背后的十个隐藏问题
点赞这个动作,用户看到的就是数字加一、图标变红。但站在后端视角,它同时触发了行为记录、计数变更、内容热度更新、用户关系变更等多条链路。业务简单,反而让所有矛盾都暴露得特别集中。
我梳理过一套高频问题清单,做点赞系统时基本都会踩到:
第一,高写入。点赞是典型的“短事务、高频率”操作,一个热门内容在短时间内可能涌入几十万次写入,单库单表根本扛不住。
第二,高读取。详情页每一次打开都要展示点赞总数和当前用户的点赞状态,读流量通常是写流量的十倍甚至更多。一百万人点赞,可能带来上千万次页面刷新。
第三,状态翻转。用户会点赞、取消点赞、再点赞,系统需要记录当前状态,但又要保存完整的行为轨迹,不能简单删行。
第四,幂等去重。同一用户对同一条内容连点一百次,最终结果必须只算一次赞。网络重试、前端重复点击、消息队列重发都会带来重复请求。
第五,热点集中。头部内容占据绝大部分流量,可能某一条内容的点赞 key 在短时间内被打爆,形成热 key。
第六,数据量膨胀。点赞明细是永久性行为数据,一个千万级 DAU 的平台,一年可能积累几十亿条记录。
第七,缓存一致性。计数先写缓存还是先写库?缓存和数据库不一致怎么办?这是所有读多写多系统都会遇到的经典矛盾。
第八,防刷对抗。恶意脚本可以在几秒钟内刷出上万赞,如果不加防护,任何架构都会被冲垮。
第九,延迟敏感。用户点击后图标要立刻变红,这个反馈是毫秒级的。任何同步写库的动作都会让体验变差。
第十,容量规划。百万级点赞不是“等到百万才设计”,而是从要支撑百万点赞的那一刻起,每一层都得按峰值流量预留容量。
这十个问题,本质上指向同一个结论:点赞系统必须用“缓存扛读写、异步做持久化、对账保一致”的思路来设计,而不是让数据库直接面对用户请求。
1.2 从 100 万赞反推流量模型:数据不会说谎
在做技术方案之前,我会先算一笔账。假设一次运营活动让某个话题在一小时内从 0 涨到 100 万赞,这 100 万次写操作平均下来就是每秒约 278 次。流量永远有波峰,瞬时峰值通常是平均值的 3 到 5 倍,也就是说单条内容的高峰写 QPS 在 800 到 1400 左右。
这只是单条内容。一个平台同时会有多条热点内容,加上常规内容的点赞流量,整体写 QPS 很容易突破 1 万到 3 万。如果再算读取,一个百万赞的内容往往伴随上千万次页面曝光,详情页每次曝光都要拉取点赞数和点赞状态,读 QPS 可以轻松到 10 万以上。
如果不做设计,这些流量直接打到 MySQL 会是什么结果?第一,热文点赞不断 update 同一行,行锁竞争会让数据库 CPU 直接飙升;第二,读写混合在同一个实例上,磁盘 IO 会成为瓶颈;第三,详情页的高并发读会迅速把连接池打满,正常的登录接口也会跟着超时。所以才需要分层架构来拆解压力。
1.3 高并发点赞的“四高两难”特征
把刚才的分析收敛一下,点赞业务可以概括为“四高两难”:
- 高写入:用户行为触发频繁,写操作简单但量极大。
- 高读取:列表页、详情页、排行榜都会读点赞数据,读 QPS 远高于写。
- 热点集中:二八法则在这里体现得淋漓尽致,少量内容占据大量流量。
- 数据量大:明细数据只增不减,需要长期存储。
两难则是:状态与计数的一致性难做,恶意流量的识别难做。前者要求技术架构有缓存、有异步、有对账;后者要求业务层有风控、有限流、有封禁策略。
理解了这些特征,后面所有的方案选型就有了依据。
2. 高并发架构选型与整体链路设计
2.1 分层模型:接入层、服务层、存储层各司其职
整个系统我习惯分成三层来设计:接入层、服务层、存储层。
接入层负责请求鉴权、参数校验、限流、风控拦截和基础 WAF,这一层可以用网关或 BFF(Backend For Frontend)实现。接入层要够“薄”,只做通用逻辑,不掺业务,这样能快速拦截恶意流量,避免无效请求打到下游。
服务层承载点赞核心业务逻辑,包括点赞状态判断、计数更新、消息发送、缓存读写。这一层是流量的主要承接者,也是系统里最关键的一环。它不直接操作数据库,而是通过 Redis 和消息队列与存储层交互。
存储层分为两块:缓存和持久化存储。缓存负责扛住绝大部分读流量和写入时的计数更新;持久化存储负责保存最终的点赞明细和计数,供后续查询、统计、对账使用。
这三层之间是单向依赖关系:接入层调用服务层,服务层依赖存储层,反向不能调用。这样设计的好处是每一层都可以独立扩展,比如存储层扛不住时,可以只扩容缓存而不动业务代码。
2.2 写入链路:Redis 原子操作 + 异步削峰落库
点赞写入是整个系统的重头戏。我的方案是:写请求到达服务层后,先操作 Redis,用 Lua 脚本保证“状态校验 + 计数更新 + 状态写入”三步的原子性,然后发送一条消息到消息队列,由消费者异步把明细数据落库。
这里给出一个简化的点赞核心流程代码示例(Java + Spring Data Redis 风格):
String countKey = "like:count:" + targetId; String statusKey = "like:status:" + targetId + ":" + userId; // Lua 脚本:原子地判断状态并更新计数 String lua = "if redis.call('get', KEYS[1]) == '1' then " + "return 0 " + "else " + "redis.call('set', KEYS[1], '1', 'EX', ARGV[1]) " + "redis.call('incr', KEYS[2]) " + "return 1 " + "end"; Long result = redisTemplate.execute( new DefaultRedisScript<>(lua, Long.class), List.of(statusKey, countKey), 30 * 24 * 3600 // 状态保留 30 天 ); if (result != null && result == 1L) { // 发送异步消息,由消费者做落库 mqTemplate.send("like-topic", buildLikeMessage(userId, targetId)); }为什么不先“读状态再写计数”?因为这是一个典型的 check-then-act 操作,在高并发下会出现竞态:两个请求同时读到未点赞状态,然后各自执行一次计数加一,用户点了一次却加了两次,幂等性直接被破坏。用 Lua 脚本把所有判断和写入放在一个原子操作里,从根上杜绝这个窗口期。
这里需要特别注意:Redis 集群模式下,多 key 操作用上述脚本会要求 KEYS 里的 key 在同一个哈希槽。解决办法是用哈希标签,例如把 key 改成like:{targetId}:count和like:{targetId}:status:{userId},这样相同 targetId 的所有 key 会落到同一槽位,Lua 脚本才能正常执行。
发消息的必要性在于“削峰填谷”。数据库能承受的写入速度是有上限的,而消息队列天生能缓存大量消息,让消费者按照数据库能接受的速度批量落库。即使瞬时流量冲高,数据库也不会被打爆,只会表现为消息积压,消费者慢慢消费,最终赶上进度。
2.3 读取链路:计数与状态缓存的批量优化
读链路如果设计不好,同样会压垮系统。详情页打开时要展示两样东西:点赞总数、当前用户是否点过赞。
计数读取相对简单,直接查 Redis String key 就行。但状态读取在高并发下有一个常见的坑:列表页同时展示 20 条内容,如果为每条内容都去 Redis 查一次“当前用户是否点赞”,一次页面请求就变成 20 次 Redis 调用,QPS 直接放大二十倍。
我的做法是:服务端缓存每个用户近期点赞过的内容 ID 集合,列表页只取一次集合,然后在内存里做判断。
具体来说,用 Redis Set 保存like:user:set:{userId},点赞时往集合里添加内容 ID,取消时移除。列表页接口一次 SMEMBERS 取出这个集合,在内存中和当前页面的内容列表做交集,就能一次性得到所有点赞状态。集合只保留最近 10000 条点赞记录,超过上限做归档清理,避免单个 Set 无限膨胀。
注意这里说的是“近期”。对于一个活跃用户,一个月前的点赞状态如果需要展示,可以从明细库反查,这种低频查询对数据库的压力完全可控。读多写多的高并发系统,本质上就是“高频走缓存、低频走库、热点走多级缓存”。
2.4 为什么不直接同步写库:延迟、锁与扩展性三角
有人会问:既然最终都要落库,为什么不直接在接口里同步写库,省掉缓存和消息队列?
同步写库有三个致命问题。第一,响应延迟从 1 毫秒变成几十毫秒,用户体验直线下降,详情页接口的 TP99 根本没法看。第二,热文场景下所有请求都会去 update 同一行,行锁竞争会让数据库吞吐量急剧下降。第三,数据库是难以秒级扩展的组件,一旦存储容量吃紧或者写入能力不足,扩容要涉及数据迁移,整个系统都跟着动荡。
所以点赞这种“允许短时间不一致、但必须最终准确”的业务,天然适合放弃强一致,采用最终一致模型。用户看到的计数是 Redis 里的实时值,数据库里的计数通过异步任务慢慢跟上,两者最终对齐即可。
3. 存储模型与一致性方案落地
3.1 点赞记录表怎么建才扛得住千万级数据
点赞明细表是所有数据的最终归宿。我的核心设计如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,雪花 ID |
| target_id | bigint | 被点赞的内容 ID |
| user_id | bigint | 点赞用户 ID |
| status | tinyint | 状态,1 点赞 / 0 取消 |
| create_time | datetime | 首次操作时间 |
| update_time | datetime | 最近操作时间 |
关键点有两个。
第一个是唯一索引。表上必须建uk_target_user(target_id, user_id)唯一索引,作用是消费端幂等。消息队列是 at-least-once 语义,可能重复投递,如果没有唯一索引,一条点赞消息被重复消费就会插入两行数据,计数和明细全乱套。
第二个是分库分表。以 user_id 做路由,例如 16 库 × 8 表 = 128 张表,路由规则是user_id % 128。为什么按 user_id 分而不是按 target_id 分?因为点赞场景需要支持“我的点赞列表”这种用户视角的查询,按 user_id 分片可以保证用户的所有记录都在同一张表里,查询直接走单表就能完成。热点内容的高并发读取由缓存层承接,分片后的表主要承接异步写入,压力被分散到多张表上。
这里有个容易被忽略的细节:status 字段必须保留,取消点赞时做 update 而不是 delete。一方面,用户行为轨迹是重要数据资产,删了就没了;另一方面,取消点赞后重新点赞,只需要把 status 改回 1,无需新增记录。唯一索引加状态字段,还能保证同一个用户对同一条内容永远只有一条主记录。
3.2 计数字段怎么存:Redis 为主、DB 为底
点赞计数有三个存储位置:Redis、DB 计数表、以及最终的汇总服务。
Redis 承担实时计数,key 设计是like:count:{targetId}。但单个 key 在超高并发下会成为热 key,我习惯把单个计数 key 再拆成多个分桶 key,例如like:count:{targetId}:bucket-1到bucket-256,写入时按 user_id 哈希取桶任意递增,读取时并行求和。这样单 key 的访问压力被平均分散到 256 个 key 上,Redis 单个实例也不容易被打满。
DB 的计数表结构很简单:target_id、cnt、update_time,用于冷启动时恢复 Redis 缓存、以及最终一致性对账。计数表的写入由消息消费者批量更新,而不是实时写。
Redis key 不能永久不过期,否则内存会被撑爆。我的经验是:热点内容设置 7 天的过期时间,非热点内容设置 3 天,并且过期时间统一加一个随机偏移量(比如 0 到 300 秒),防止大量 key 在同一秒集体失效形成缓存雪崩。计数被过期清除后再被读到,就回源 DB 重新加载,对用户而言只是一次额外延迟,不会影响正确性。
3.3 最终一致性:增量刷新、对账任务与兜底补偿
到了这一步,系统里会出现两份计数:Redis 里一份,DB 里一份。两者必然存在时间差,最终一致性系统的关键,就是让这个时间差控制在业务可接受的范围内,并且保证最终的收敛。
我的方案分三层。
第一层,消息消费者批量落库。消费者从 MQ 拉取一批点赞消息,先通过批量 SQL 更新点赞明细表,再更新计数表。重点在“批量”:一次拉取 500 条,处理完后统一提交偏移量,比一条一条处理快得多。
第二层,周期对账。每 5 分钟跑一次对账任务,扫描所有可能不一致的目标内容,比较 Redis 中计数分桶的求和结果和 DB 计数表里的值,差值超过阈值就进入补偿队列。补偿任务把 Redis 的真实值同步到 DB,执行完再次校验。
第三层,异常兜底。如果补偿任务连续重试多次仍然失败,说明数据链路出了问题,这时候必须触发告警,人工介入排查。最终一致性的底线是:可以有延迟,但不能有误差。
为什么不选“同步更新 Redis 和 DB”这种强一致方案?因为跨存储的强一致要么需要分布式事务,要么会引入巨大的性能损耗。点赞业务允许计数有几十秒的延迟,用户不会感知到,为这点延迟付出这么大代价完全不划算。
4. 热点、幂等与防刷的三重防线
4.1 必须防的三种缓存事故:穿透、击穿、雪崩
缓存层是点赞系统的第一道防线,也是最容易出事的环节。
缓存穿透是指请求的数据在 Redis 和 DB 中都不存在。解决办法是缓存空值并设置短过期时间,或者在前置层加布隆过滤器,把不存在的 target_id 直接拦截掉。
缓存击穿更像“保温杯里没水了,一群人同时跑去水房”。某个热门的计数 key 刚好在瞬间过期,大量请求同时回源数据库,数据库瞬间被打挂。解决办法是互斥锁:只放一个请求回源加载数据并重建缓存,其他请求短暂等待后直接读新缓存。这个场景我用分布式锁实现,锁粒度就是 targetId。
缓存雪崩则是大量 key 同时过期。解决思路很简单:过期时间加随机偏移量,让失效时间错开。
这三类事故的防线必须前置,因为它们一旦发生,打挂的不是 Redis 而是数据库,恢复成本非常高。
4.2 热 key 识别与定向化解:分桶、本地缓存、边缘缓存
热 key 是点赞系统真正的“隐形杀手”。一条内容突然被推荐,它的计数 key 的 QPS 可能瞬间从几十涨到几十万。Redis 是单线程模型,一个 key 的访问如果集中在同一个分片上,单个 CPU 核心的瓶颈就会被触发。
我的处理思路是三层递进。
第一层,分桶已经在前文提到。写入侧将计数按 user_id 哈希分散到 256 个 key,读取侧求和,这样单个 key 的 QPS 上限被限制在总流量的 1/256 左右。
第二层,服务本地缓存。内容详情页接口读取计数时,在服务进程内部再缓存一层,例如 Caffeine 缓存 5 秒。热点内容 5 秒内所有请求都由本地缓存返回,根本不打到 Redis。
第三层,边缘节点缓存。如果用 CDN 或网关层比较重的架构,可以对详情页做静态化,把点赞数定时推到边缘节点,用户读完边缘节点的数据后再回源校验。
热 key 的判断不能靠感觉,要落监控。Redis 的object idletime可以查看 key 的访问间隔,但更可靠的是在服务侧统计每个 key 的访问 QPS,超过阈值自动触发本地缓存。
4.3 幂等三件套:缓存标记、唯一键、消息去重
点赞系统最容易出的线上事故,就是“用户点一下,计数加了五次”。要防住它,需要三层幂等防线协同工作。
第一层是缓存标记,也就是 Lua 脚本里对状态 key 的判断。同一用户对同一内容的第一次请求会把状态置为 1,后续请求在 Redis 里查到状态已经是 1,直接返回“已点赞”而不再加计数。这一层扛住了绝大多数的用户重复点击和网络重试。
第二层是数据库唯一键。即使前两层被绕过,消息落到 DB 时,uk_target_user唯一索引也会挡住重复插入。注意插入要用INSERT ... ON DUPLICATE KEY UPDATE语句,这样重复插入不会报错,而是原地更新 create_time 或 update_time,保持数据幂等。
第三层是消息消费去重。消费者侧对每条消息先执行一次SETNX like:consume:{messageId},只有首次消费返回成功才继续处理。消息队列重复投递时,这条消息会被直接跳过。三件套缺一不可,单靠任何一层都可能被极端并发击穿。
4.4 防刷:接口限流与行为风控双管齐下
防刷不能只靠技术手段,还要结合业务风控规则。
技术层很简单,接入层对每个用户做滑动窗口限流。比如点赞接口限制为每分钟 10 次、每小时 50 次。这个阈值要结合业务真实情况来定,太严会误伤正常活跃用户,太松起不到拦截作用。除了用户维度,还要做 IP 维度限流、设备维度限流,防止脚本批量注册小号刷赞。
风控层要对行为特征建模。正常用户的点赞行为是分散的、有间隔的,而脚本的特征是短时间内高频、规律性极强、目标内容集中。这个特征写入风控规则引擎,命中后直接拒绝请求并进入人工审核名单。
防刷有个原则:宁可慢半拍,不要误杀。误杀正常用户导致的投诉和流失,比放过一波刷量更伤。所以封禁动作最好做分级——先限流、再降级展示、最后才封禁,给误判留出纠错空间。
5. 容量预估、压测调优与故障排查实录
5.1 从业务峰值反推资源清单:宁可多备,不可少算
容量预估不是拍脑袋,而是从业务数字反推资源。我习惯按以下流程计算。
第一步,估算写峰值 QPS。假设产品目标是一个月做到 500 万 DAU,点赞转化率约 5%,每天产生约 25 万次点赞。这些点赞集中在晚上 2 个小时的高峰时段,平均 QPS 约 35,峰值按 5 倍估算为 175 QPS。这个量级看似不高,但如果是某条头部内容被推上热门,单条内容可能瞬间再增加数千 QPS,所以我把整个系统的写容量定位在峰值流量 10 倍以上,即至少支撑 3000 QPS 的写请求。
第二步,估算读峰值 QPS。详情页和列表页的读流量通常是写流量的 10 到 20 倍,也就是至少 3 万到 6 万 QPS。这个量级对 Redis 没有压力,但要求列表页接口必须做批量读取和本地缓存,不能让每条内容的读流量都透传到缓存层。
第三步,确定存储规模。100 万用户产生 1000 万条点赞明细,按每条记录 128 字节计算,约 1.2 GB。这个量级 128 张表完全能承载,但如果是 2 亿条明细,就要考虑冷热分离和归档。
资源清单我通常会做成对比表格:
| 组件 | 容量需求 | 实例规格 | 关键指标 |
|---|---|---|---|
| Redis | 3000 QPS 写 / 6 万 QPS 读 | 集群 6 主 6 从 | 平均延迟 < 5ms |
| MQ | 3000 消息/秒写入 | 3 节点 | 积压可容忍 30 分钟 |
| MySQL | 3000 行/秒批量写入 | 16 库 8 表,主从 | 写入延迟 < 20ms |
| 服务节点 | 6 万 QPS 读 + 3000 QPS 写 | 20 个 8C16G 实例 | TP99 < 80ms |
5.2 压测实操:工具、场景与通过标准
压测是验证设计是否成立的唯一手段。工具上我用 wrk 打基础压力,用自研脚本模拟真实点赞行为。压测不是简单地对接口打流量,而是分层压、分场景压。
第一层压 Redis 链路:直接对 Lua 脚本写入接口压测,验证纯 Redis 操作能支撑多少 QPS。第二层压完整写入链路:接口 + Redis + MQ 的联动,验证消息发送速度。第三层压消费者链路:拉高消息生产速度,观察消费者批量落库能否跟上,峰值时积压多少。第四层压读接口:列表页和详情页的 TPS 和 TP99 数据。
通过标准我定得很明确:写入接口 TP99 小于 30ms,读取接口 TP99 小于 80ms,错误率小于 0.1%,Redis CPU 低于 70%,消息积压在活动结束后 30 分钟内消费完毕。
压测中踩过一个典型坑:消费者批量 SQL 没有真正“批量”。当时我把攒够 500 条消息后循环单条执行 update,性能一直没有提升,排查发现是循环里每次执行都会单独开启事务、单独提交,和一条一条写没有本质区别。改成一条多值 SQL 批量插入后,吞吐立刻翻了 8 倍。这个细节不压测根本发现不了。
5.3 线上故障排查速查表
结合我自己的实战经验,整理一份高频问题速查表,基本覆盖点赞系统上线后最容易遇到的几类故障:
| 现象 | 可能原因 | 排查命令 / 步骤 | 解决手段 |
|---|---|---|---|
| 点赞数长时间不变化 | Redis key 过期或被清,回源失败;对账任务挂起 | 查看 Redis key 是否存在,检查对账任务日志 | 手动回源加载计数,重启对账任务 |
| 同一用户疯狂点赞,计数值异常高 | 幂等失效,Lua 脚本被新版本覆盖 | 检查 Redis 状态 key 是否正确写入 | 回滚脚本,补唯一索引校验 |
| 接口 RT 突然飙升 | Redis 连接池打满或出现热 key | redis-cli --latency、查看慢查询日志 | 扩容连接池、启用本地缓存 |
| MQ 消息积压持续增长 | 消费者 batch 太小或 DB 连接数不足 | 查看 MQ 积压数和消费者日志 | 调大 batchSize、扩容消费者节点 |
| 计数与 DB 对不上 | 消息消费丢失或重复消费 | 跑对账 SQL,对比 Redis 求和与 DB 值 | 补偿任务强制同步,定位丢失环节 |
处理这类故障有个总原则:先恢复服务,再定位根因。点赞计数短暂不准确用户可以接受,但接口超时和全站不可用绝对不能容忍。遇到故障时,先摘掉异常节点、降级非核心功能,保证核心链路可用,再慢慢查数据问题。
5.4 上线后要盯的指标与持续演进方向
上线不等于结束。我每天盯的指标有四个:Redis 内存使用率和 QPS 波动、MQ 积压数量、DB 慢查询数量和延迟、对账任务的差异量。这四个指标分别对应缓存健康度、链路吞吐、存储性能和一致性收敛情况,任何一个异常都值得立刻排查。
持续演进上,我的规划是分三步走。第一步引入多级缓存,在边缘节点做更多静态化,减少对核心 Redis 的依赖。第二步做数据冷热分层,超过三个月的点赞明细迁入冷存储,热库只保留近三个月数据,大幅压降存储和索引成本。第三步是根据流量特征做自动扩缩容,活动开始前提前扩容写入链路,活动结束后自动缩容,节省资源。
点赞系统做到这个程度,已经不只是“一个数字加一”的功能了,它能像一面镜子,清晰地照出整个后端体系在高并发下的短板。把这些功课做扎实,再去做订单、库存这类强一致性系统,心里的底气会完全不同。
最后说点个人体会。我始终觉得,点赞是高并发系统最好的入门课题,因为它足够简单,简单到可以把全部精力放在架构本身;它又足够复杂,复杂到涉及缓存、队列、分库、幂等、风控、压测、监控的每一个环节。踩过几次坑之后你会发现,真正难的不是某个技术点,而是当流量突然翻倍时,你能否在几分钟内判断出瓶颈在哪一层,并果断地做出取舍。把这套能力练出来,再去看其他高并发场景,基本就是降维打击了。