一次技术分享结束有位同学问我“点赞接口到底难在哪不就是一条 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这一层可以用网关或 BFFBackend 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 点赞记录表怎么建才扛得住千万级数据点赞明细表是所有数据的最终归宿。我的核心设计如下字段类型说明idbigint主键雪花 IDtarget_idbigint被点赞的内容 IDuser_idbigint点赞用户 IDstatustinyint状态1 点赞 / 0 取消create_timedatetime首次操作时间update_timedatetime最近操作时间关键点有两个。第一个是唯一索引。表上必须建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 亿条明细就要考虑冷热分离和归档。资源清单我通常会做成对比表格组件容量需求实例规格关键指标Redis3000 QPS 写 / 6 万 QPS 读集群 6 主 6 从平均延迟 5msMQ3000 消息/秒写入3 节点积压可容忍 30 分钟MySQL3000 行/秒批量写入16 库 8 表主从写入延迟 20ms服务节点6 万 QPS 读 3000 QPS 写20 个 8C16G 实例TP99 80ms5.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 连接池打满或出现热 keyredis-cli --latency、查看慢查询日志扩容连接池、启用本地缓存MQ 消息积压持续增长消费者 batch 太小或 DB 连接数不足查看 MQ 积压数和消费者日志调大 batchSize、扩容消费者节点计数与 DB 对不上消息消费丢失或重复消费跑对账 SQL对比 Redis 求和与 DB 值补偿任务强制同步定位丢失环节处理这类故障有个总原则先恢复服务再定位根因。点赞计数短暂不准确用户可以接受但接口超时和全站不可用绝对不能容忍。遇到故障时先摘掉异常节点、降级非核心功能保证核心链路可用再慢慢查数据问题。5.4 上线后要盯的指标与持续演进方向上线不等于结束。我每天盯的指标有四个Redis 内存使用率和 QPS 波动、MQ 积压数量、DB 慢查询数量和延迟、对账任务的差异量。这四个指标分别对应缓存健康度、链路吞吐、存储性能和一致性收敛情况任何一个异常都值得立刻排查。持续演进上我的规划是分三步走。第一步引入多级缓存在边缘节点做更多静态化减少对核心 Redis 的依赖。第二步做数据冷热分层超过三个月的点赞明细迁入冷存储热库只保留近三个月数据大幅压降存储和索引成本。第三步是根据流量特征做自动扩缩容活动开始前提前扩容写入链路活动结束后自动缩容节省资源。点赞系统做到这个程度已经不只是“一个数字加一”的功能了它能像一面镜子清晰地照出整个后端体系在高并发下的短板。把这些功课做扎实再去做订单、库存这类强一致性系统心里的底气会完全不同。最后说点个人体会。我始终觉得点赞是高并发系统最好的入门课题因为它足够简单简单到可以把全部精力放在架构本身它又足够复杂复杂到涉及缓存、队列、分库、幂等、风控、压测、监控的每一个环节。踩过几次坑之后你会发现真正难的不是某个技术点而是当流量突然翻倍时你能否在几分钟内判断出瓶颈在哪一层并果断地做出取舍。把这套能力练出来再去看其他高并发场景基本就是降维打击了。
阅读完成 · 觉得有帮助?