对于互联网应用来说,点赞、收藏、浏览量这类计数看似简单,却是最能体现架构功底的地方。早期用数据库直接update,用户量低的时候没问题,等日活上来一声不吭就把库拖死。做中间层缓存是必然选择,但缓存也不是把incr塞进Redis就完事——单Key热点、重复点赞、数据落库这三件事,每一项拆开都有讲究。这篇内容主要围绕一个模拟社区项目的实践,聊聊如何用Redis的Key分区分片来解决高并发计数写入,再配合布隆过滤器拦住重复点赞,最后把增量计数安全地同步回数据库。如果你正在设计类似的功能,或者已经上线但被计数不准、性能毛刺折腾得不轻,这篇应该能给你一个可落地的参考。
1. 计数直写数据库为什么走不远:一个点赞按钮引发的连锁问题
先说一个很典型的场景。某社区App上线了动态点赞功能,用户点一下赞,后端执行一条UPDATE t_comment SET like_count = like_count + 1 WHERE id = ?。在初期,这种写法逻辑简单、数据一致性强,几百台业务机器支撑几千QPS的写入也不会出大问题。
但问题出在热点资源上。一条上了首页的爆款内容,可能在几分钟内被点击上万次点赞。数据库这一行记录所在的B+树页被反复加锁修改,主库的写压力瞬间拉高。更要命的是,UPDATE语句本身会触发行锁竞争,一条热门内容点赞的TPS一旦超过几百,数据库连接池就会被这些更新语句占满,后面正常的读写请求全部排队等待,整个服务的可用性跟着崩。
有人会想到把计数更新改成异步MQ,但这只是把压力从数据库移到了另一个地方。MQ的消费吞吐如果追不上生产速度,积压一上来,计数延迟就越拉越长,用户看到的点赞数几小时不刷新,体验很差。而且就算是异步,最终还是要对同一行数据做加法,热点行锁的问题依旧存在——只不过从同步放大变成了异步堆积。
另一个容易被忽略的问题是重复点赞。用户手一抖就可能连点两下,前端做了防抖也只能挡住最明显的那一层。客户端可以禁用按钮,但接口层面的重复请求没办法靠前端根治。一旦数据库里有两条点赞记录,like_count就会多算一次。大多数项目会额外建一张like_log表,每次点赞先查一遍有没有记录,有就拒绝。但高并发之下这条查询本身就会成为数据库的负担:点赞一次要查一次,热门内容的点赞率那么高,查询量和写库量直接翻倍。
我见过有团队在这个阶段坚持用数据库硬扛,然后给like_log表加了各种索引优化,最后瓶颈变成了自增主键的写入速度和唯一索引检查的CPU开销。业务高峰期CPU跑满,慢查询一堆,最终还是要回到缓存这条路上来。
2. 先写Redis再异步落库:分区分片方案的整体思路
用Redis做计数的核心思路很简单:点赞的时候不直接改数据库,先把累加操作落到Redis,由后端的同步任务把Redis里的增量定期刷到数据库。这样数据库的写压力从每秒几千次下降到每秒几十次,热点行的锁竞争自然缓解。
但"落到Redis"具体怎么落,这里有一个反直觉的坑:很多框架教程会直接写INCR like_count:12345,这一个Key在单机上扛单点流量其实没问题,可一旦点赞数破百万、上千万,或者遇到集群部署,就有麻烦。
单Key热点的第一个问题是Redis单线程模型。一条INCR命令在极端高并发下会被集中压到一个实例、一个Key上。假设某个热门内容的点赞请求每秒有5万次,这个Key所在的Redis实例CPU就会迅速打满,命令处理延迟从亚毫秒级飙到几十毫秒甚至更久。其他的Key哪怕本身没有热点,只要和这个Key落在同一个实例上,都会被殃及。
第二个问题是同步数据库时的数据聚合。如果只用一个Key记录总数,同步任务读一次、清零一次,中间如果Redis重启或者同步任务与写操作并发,非常容易丢数据。而且单Key无法回答"这个用户在这个内容上点过赞吗"这种集合型问题——这需要另外一个数据结构存储用户维度的状态,和计数分开管理。
第三个问题是Redis集群环境下的数据倾斜。当Key做了哈希槽分配,单Key的点赞计数只落在一个节点上。哪怕你横向扩容了几十个节点,热点流量仍然压在一个节点上,集群扩容对热点Key几乎无效。反过来,故意控制Key的分布让不同内容的计数散落到不同节点,效果反而显著。
所以标题里的"分区分片"不是空话,它解决的是两个具体问题:一是把一个热门Key拆成多个子Key,分散写压力和热力;二是为后续聚合同步数据库提供一个可控的中间层。分片方案的整体结构分四层:第一层是布隆过滤器判断是否重复点赞;第二层是按规则拆分后的多个Redis分片Key,执行计数累加;第三层是后台聚合任务,把一段时间内的分片增量汇总;第四层是数据库批量写入。下面几节分别展开。
3. Key分区分片的设计:分片键组成与取模策略
分片键的设计是所有工作的基础。我在模拟项目X里采用的方案是把计数拆成like:cnt:{contentId}:{shardIndex},其中shardIndex是contentId哈希取模的结果。contentId本身作为业务主键,取模后得到0到N-1之间的桶号,N就是分片桶的总数。
有的团队会用随机数来分片,每次点赞时随机选一个桶。随机分片的优点是流量绝对均匀,但缺点也很明显:同一个内容的计数被分散到N个桶之后,聚合扫描时需要遍历所有前缀的Key,而且没有办法预判某个前缀下到底有没有数据。取模分片虽然会因为contentId的哈希分布产生一定的倾斜,但在内容数和桶数都足够大的情况下,倾斜的概率很低,而且聚合时可以直接按0到N-1的顺序扫,逻辑更清爽。
桶数量N的选择直接影响后面的效率和运维成本。这里有一个权衡:N太小,分片效果不明显,热点内容可能还是会在少数几个桶上堆积;N太大,每次聚合扫描的Key数量太多,浪费时间和网络IO。我一开始设的是16个桶,压测的时候发现热门内容的点赞QPS仍然集中在两三个桶上,后来改成64,效果立刻好了很多。但如果内容ID的量级很小、总量在百万级以下,16个桶其实也够用。
更讲究一点的方案是两层分片:在Redis里用一个String保存分片桶的幂等标识位,用另一个String保存计数。分片键前缀还可以加入时间维度,比如按小时分桶。加入时间桶的优势是,同步任务可以按时间窗口把数据刷走,刷完直接删除整个时间段的Key,天然避免累积。我之前在某个评论计数场景里测试过时间桶方案,一天一个Key,同步后删除,Redis内存增长可控,也不需要额外的数据清理任务。缺点是聚合逻辑要处理跨天跨小时的情况,比纯取模复杂一些。
public class LikeCountShardRouter { // 分片桶数量,根据预估的QPS和Redis实例规模调整 private static final int BUCKET_COUNT = 64; public static String shardKey(long contentId) { int bucket = Long.hashCode(contentId) & (BUCKET_COUNT - 1); return "like:cnt:" + contentId + ":" + bucket; } public static List<String> allShardKeys(long contentId) { List<String> keys = new ArrayList<>(BUCKET_COUNT); for (int i = 0; i < BUCKET_COUNT; i++) { keys.add("like:cnt:" + contentId + ":" + i); } return keys; } }上面这个shardKey方法里用了一个细节:BUCKET_COUNT固定为2的幂次方,所以可以用& (BUCKET_COUNT - 1)代替% BUCKET_COUNT。位运算比取模快,而且当桶数量是2的幂时,哈希低位分布均匀度足够。如果桶数不是2的幂,这里改成Math.floorMod(Long.hashCode(contentId), BUCKET_COUNT)就行,注意别直接对负数做%。
实际上位运算的收益在单次请求里微乎其微,但积少成多,在每天几千万次点赞的体量下,少几次取模运算也是一点算力。更重要的是,取模分片之后,同一个contentId的计数会均匀地落在多个Redis节点上,集群模式下的热点也就被天然打散了。
3.1 分片桶数量的公式估算
桶数量不是拍脑袋定的。我习惯用一个粗略的公式来估算:
单桶可承受写入QPS = Redis单实例建议写入QPS × (1 - 其他业务占用比例) 桶数量 = 预估核心内容的峰值点赞QPS / 单桶可承受写入QPS假设单实例能扛8万QPS写入(这里指的是Redis Benchmark的常规数字,实际生产一般留一半余量),留出50%给业务读写,那么单桶可承受的写入QPS大概在4万左右。如果峰值时一个爆款内容的点赞QPS是10万,取模分片后理论上8个桶就够。但实测中哈希分布不可能绝对均匀,所以我会把预算打两倍到三倍的富余量,最终落到32到64个桶。
桶数的上限要注意,别为了保险设到1024。分片Key越多,聚合扫描的开销越大。比如一个内容有10万点赞,分布在64个桶里,聚合时最多扫64个Key;如果桶数改成1024,一次聚合最多要扫1024个Key,虽然每个Key只是一个很小的String,但网络往返次数和命令执行时间都会明显上升。
3.2 点赞写入路径的完整流程
结合分片和后续的布隆过滤器,一次点赞请求的完整写入路径是这样的:
- 用户请求到达业务接口,带上
userId和contentId。 - 先用布隆过滤器判断这个用户是否已经对该内容点过赞。如果返回"可能已存在",则走兜底查询(后面细讲);如果返回"肯定不存在",继续。
- 通过路由算法得到当前
contentId对应的分片桶Key,执行INCR。 - 同时向布隆过滤器里写入这个
userId + contentId的组合标识。 - 返回点赞成功。
- 后台聚合任务定时扫描分片Key,把增量汇总后写入数据库。
点赞取消的情况要单独考虑。用户取消点赞后,需要执行DECR,同时布隆过滤器里的标识还在。这时候布隆过滤器会判定该用户"可能已经点过赞",导致他再次点赞时被拦截。处理方式很简单:取消点赞时,除了DECR,还要把该用户的点赞状态记录到另一个Redis结构里(比如一个Set或Hash)作为兜底。这样布隆过滤器只负责拦住第一次的高频重复请求,真正的状态判断交给持久化存储或者兜底Key。
4. 布隆过滤器防重复点赞:原理、代价与正确用法
布隆过滤器是去重场景里一个很有意思的工具。它的特点是把一个元素映射到比特数组里的多个位,查询时如果这些位里有任何一个为0,那这个元素一定不存在;如果全为1,只能说"可能存在"。这意味着布隆过滤器永远不产生漏报,但偶尔会产生误报。
对应到防重复点赞的场景:如果布隆过滤器说"这个用户没点过赞",那他一定没点过,可以直接放行;如果它说"点过了",其实是"可能点过了",需要进一步查证。妙就妙在,误报只是让极少数的二次点赞请求多走一次兜底查询,并不影响业务正确性,而重复点赞请求里绝大部分都能被快速拦截。
为什么不用Redis的Set直接存点赞用户?答案在内存占用。一个用户ID加内容ID的组合,在Redis Set里以String形式保存,内存开销约在几十字节。假设每天有500万对点赞关系,光这个Set就要占用几百MB内存。布隆过滤器同样容量的数据,按万分之一的误判率设计,占用的内存大概在几十MB量级,相差一个数量级以上。更进一步,Set在判断"是否存在"时有哈希碰撞的内部开销,Key数量大了之后Redis的哈希表扩容也会带来性能抖动。
4.1 参数计算与初始化
布隆过滤器有两个关键参数:预期元素数量n和误判率p。位数组长度m和哈希函数数量k由这两个参数推导出来:
m = -n * ln(p) / (ln(2)^2) k = m / n * ln(2)举个例子。预期要存储的点赞关系数量是1000万条,允许的误判率是万分之一,也就是p=0.0001。代入公式:
m = -(10000000 * ln(0.0001)) / (ln(2)^2) ln(0.0001) ≈ -9.2103 ln(2)^2 ≈ 0.4805 m ≈ (10000000 * 9.2103) / 0.4805 ≈ 191,700,000 位 ≈ 22.85MB k = (191700000 / 10000000) * ln(2) ≈ 13.28,取13也就是说,用一块约23MB的位数组加13个哈希函数,就能在万分之一的误判率下容纳1000万条点赞关系。这个内存量对Redis来说非常轻松。
4.2 Java实现:基于BitSet的自研方案
开源的布隆过滤器库很多,比如Guava自带BloomFilter,但它是应用内存态,重启就丢了。我们这里的场景要求布隆过滤器保存在Redis里,才能让多个业务实例共享判断结果。两种实现方式都是可以的:一种是直接用Redis自带的位图操作配合多个哈希函数完成,另一种是把位数组序列化后缓存到Redis里一次性加载。我推荐前者,因为Redis的SETBIT和GETBIT都是O(1)操作,而且天然支持分布式。
自研实现的核心是多个哈希函数怎么设计。简单可靠的做法是使用两个基础哈希函数,然后推导出第i个哈希函数的值:
hash_i = (h1 + i * h2) % m其中h1和h2可以通过MD5、MurmurHash或其他算法取两个不同维度。Java的String.hashCode()不够好,碰撞概率偏高,建议用Guava的Hashing.murmur3_128()。
import com.google.common.hash.Hashing; public class RedisBloomFilter { private static final int BIT_SIZE = 191_700_000; // 根据上面的公式计算 private static final int HASH_FUNCTION_COUNT = 13; private static final String KEY_PREFIX = "bloom:like:"; public static void add(String redisKey, long userId, long contentId) { String element = userId + ":" + contentId; for (int i = 0; i < HASH_FUNCTION_COUNT; i++) { long hash = hash(element, i); long offset = hash % BIT_SIZE; redisTemplate.opsForValue().setBit(KEY_PREFIX + redisKey, offset, true); } } public static boolean mightContain(String redisKey, long userId, long contentId) { String element = userId + ":" + contentId; for (int i = 0; i < HASH_FUNCTION_COUNT; i++) { long hash = hash(element, i); long offset = hash % BIT_SIZE; Boolean bit = redisTemplate.opsForValue().getBit(KEY_PREFIX + redisKey, offset); if (bit == null || !bit) { return false; } } return true; } private static long hash(String element, int index) { long h1 = Hashing.murmur3_128(0).hashString(element, StandardCharsets.UTF_8).asLong(); long h2 = Hashing.murmur3_128(1).hashString(element, StandardCharsets.UTF_8).asLong(); return Math.floorMod(h1 + (long) index * h2, BIT_SIZE); } }murmur3_128(0)和murmur3_128(1)的区别在于传入的种子不同,这样得到的两个哈希值相互独立。index从0到12,13个偏移量都会落在位数组长度范围之内。
需要提示一点,使用Redis位图方案时,位数组长度约1.9亿位,也就是约23MB,SETBIT操作要设置到offset,Redis的位图支持字符串类型,SETBIT key offset value在偏移量较大时会自动扩展字符串。这个操作需要时间,但一次点赞最多做13次SETBIT,稳定在亚毫秒级,完全可接受。
4.3 布隆过滤器日常维护与重置策略
布隆过滤器不是一建永逸的东西。位数组里的位一旦被置1就永远为1,随着数据量持续写入,误判率会越来越高。实测数据表明,当实际存储的元素数量超过预估值的5倍时,误判率可能从万分之一恶化到百分之一,这时候用户正常点赞会被频繁误判,体验明显变差。
维护策略有几个方向。最简单的是定时重建:每天凌晨业务低峰期,新建一个次日使用的布隆过滤器Key,把当天的数据通过离线任务重新灌进去,第二天业务流量切到新Key。旧Key等两天的查询期过去后直接删除。这个策略的前提是可以接受计数延迟和一次性重建成本——重建时要遍历一遍已有的点赞关系,比如用SCAN扫历史点赞表,重新写入。
如果不想每天重建,也可以用分片布隆过滤器:按日期分桶,每天一个Key。比如bloom:like:20250601只存当天新增的点赞关系,查询时先查当天,再查前一天。这种方案的误判率会随着天数的增加而上升,但恢复起来很灵活:某天的数据不需要了直接删掉对应Key,其他天的数据互相独立。缺点是查询时要做多Key的GETBIT,逻辑稍微复杂一点。
同步任务消费完当天的计数之后,天级的布隆过滤器还有另一个价值:它记录了过去一天到底有哪些用户点了赞,可以反过来校验数据库里的计数是否准确。把布隆过滤器当作一份高压缩的"点赞流量日志"来用,是一个很实用的思路。
5. 计数聚合与数据库同步:从64个分片Key到一行记录
分片解决的是写入扩展性的问题,但用户看的时候需要一个总数、数据库也需要一行准确的计数记录。这就要把分散在64个分片Key里的增量汇总起来,再同步到数据库。
5.1 数据聚合策略:定时扫描还是事件驱动
数据聚合的触发方式可以根据业务容错性和实时性要求来选择。
第一种是定时任务扫描。后台每隔30秒或1分钟执行一次,遍历所有contentId的64个分片Key,用MGET一次性取出,求和后与数据库里的上一次数值比对,差值写入数据库。这个方案的优点是实现简单,Redis和数据库的交互次数可控,无论点赞请求多么密集,同步频率都是恒定的。缺点是计数延迟在0到任务周期之间浮动,高峰期可能用户点完赞30秒后数字才更新。
第二种是事件驱动。点赞请求完成后,同时发送一条消息到消息队列,消费者攒一批后批量更新。这个方案能让计数延迟降到秒级以内,但需要额外引入消息组件,而且积压时同样会出现延迟变大的问题。我在实际项目中用的是定时扫描,因为点赞计数并不是强实时业务,30秒的延迟完全可接受,少一个消息组件,运维面也小一些。
聚合时要注意的细节是,不要直接GET完再DEL。如果GET和DEL之间存在并发写入的窗口,那一瞬间写入分片的计数就会被DEL丢掉,造成计数永久性丢失。更稳妥的做法是用GETDEL这类原子操作,读取后立即删除,或者干脆把计数累加到另一个"已同步总量"Key上,然后删除分片Key。
public class LikeCountSyncTask { @Scheduled(fixedDelay = 30_000L) public void syncToDatabase() { // 假设这里的contentIds来自一个待同步的游标分页 List<Long> contentIds = fetchPendingContentIds(); for (Long contentId : contentIds) { List<String> keys = LikeCountShardRouter.allShardKeys(contentId); List<String> values = redisTemplate.opsForValue().multiGet(keys); long increment = 0L; for (String value : values) { increment += Long.parseLong(Optional.ofNullable(value).orElse("0")); } if (increment == 0L) { continue; } // 更新数据库中的like_count字段 likeCountMapper.increaseCount(contentId, increment); // 同步完成后,逐个删除分片Key,注意不要并发写 for (String key : keys) { redisTemplate.delete(key); } } } }上面这段代码演示了同步逻辑,但有一个潜在问题:同步任务执行期间,如果仍有用户继续点赞,删除Key的动作可能会误删新写入的数据。这个问题怎么解?
5.2 并发窗口:同步任务与点赞请求的竞争
在同步任务读取分片Key的值、然后删除Key的这几十毫秒窗口里,如果正好有新的点赞请求执行了INCR,那么删除操作会把新增的计数一并删掉,那部分点赞就丢了。
解决这个问题的思路比较直接:删除之前要确认Key的值没有被并发修改。由于Redis单线程执行命令,INCR和DEL之间天然是串行的,问题只存在于"读到旧值后、DELETE之前又来了新INCR"的这个空档。要避免这个窗口,可以从架构上做调整:
第一种做法,把删除动作前置到读取动作之前。先GETDEL,拿到绝对值,再把这批增量累加到总数Key,最后写库。但GETDEL删掉之后,后续的INCR会从0重新开始,所以需要在写库失败时提供补偿机制。
第二种做法,给分片Key加一个版本号标记。每次同步任务记录当前时间戳或批次号,写入的计数只承认某个时间点在当前版本之前的部分。这个思路在定时扫描场景里过于复杂,我没有采用。
第三种做法更符合一致性要求:不在同步任务里直接删分片Key,而是把分片Key的计数原子地累加到一个"待同步总账"Key里,归零分片。之后由总账Key负责和数据库交互。这样一来,点赞请求永远不会被打断,同步任务实质上移动的是"待同步金额",而不是删除活跃Key。这个方案的代价是Redis里多一个Key的读取和写入,但逻辑上最安全。
我在模拟项目X里最初使用了直接MGET加DELETE的方案,低峰期跑了一个多月没出问题。直到有一天做压测,发现并发点赞下计数偶尔比实际值少几十,排查后确认就是删除窗口导致的丢失。后来改成"读取计数、累加到总账Key、归零分片Key"的三步操作,问题才彻底解决。
5.3 数据库写入:合并更新与批量优化
数据库同步的写入要尽可能批量。逐条UPDATE在数据量大时性能并不好,更推荐的做法是准备一个like_count变更的临时表,应用层攒一批(contentId, increment),然后合并成一条SQL批量更新:
UPDATE t_content_like SET like_count = like_count + CASE content_id WHEN 1001 THEN 12 WHEN 1002 THEN 34 WHEN 1003 THEN 8 END WHERE content_id IN (1001, 1002, 1003);这样一次RPC和数据库交互可以用十几行SQL处理掉成百上千个内容的计数更新。当然,批量条数不宜过大,一般控制在100到500条之间,避免SQL语句过长和行锁持有时间过久。更新之后记得把对应的分片Key在Redis里清零,保证重复同步时不会产生多计。
还有一点需要考虑:如果同步任务执行中和业务订单系统有事务关联(比如点赞的同时要写入一条点赞日志来支撑后续的"我的点赞"列表),那数据库这边就不光更新计数了,还要真实地插入一条like_log。这种情况下数据的一致性要求更高,简单的定时扫描就不能保证严格不重复。更稳妥的做法是:
- 点赞请求先把计数的增量落到Redis,同时把点赞明细写入一个待落库的消息队列。
- 同步任务消费消息,先尝试插入
like_log,如果插入冲突说明这条点赞明细已经存在,直接跳过计数累加。 like_log插入成功后,再把计数从Redis累加到数据库。
这种做法能把计数累加和明细记录解耦,即使同步任务重复执行,只要like_log有唯一索引,也就不会重复插入。实际项目中我遇到过同步任务和点赞逻辑并发导致插入顺序不一致的问题,最后依靠唯一索引兜底,才保证数据最终一致。
6. 布隆过滤器与分片方案的配套:整个链路里的细节问题
把布隆过滤器和分片计数整合到一条链路上之后,需要注意几个边界问题,这些通常在书本上看不到,只有实际压测和线上故障才会暴露。
6.1 误判的兜底:布隆过滤器说"点过赞"但实际没有
布隆过滤器无法区分"真的点过"和"误判",所以凡是返回mightContain=true的请求,不能直接拒绝,要进入一个兜底流程去数据库或者Redis里的状态Key查询真实点赞记录。这个兜底查询只会在新增点赞量很大时被触发,但就是因为"可能点过赞"的人相对来说是少数,整体查询开销才控制得住。
我为这个场景设计了一个兜底缓存的Hash结构:like:status:{contentId},字段名是userId,值为1或0。点赞时HSET写入1,取消点赞时HSET写入0或者HDEL。查询真实状态时优先查这个Hash,没有再到数据库查。这个Hash只对布隆过滤器判定"可能存在"的请求开放,不会成为热点。它的内存占用比纯Set小得多,因为一个Hash里的字段是共享Key的,但相比于布隆过滤器仍然偏大,所以放在兜底层里很合理。
6.2 初始化与数据预热
布隆过滤器的初始化时机同样有讲究。如果系统上线前没有任何历史数据,直接建一个空过滤器用就行。但如果是老系统改造,已经累积了上百万条点赞关系,就必须先把这些数据灌进布隆过滤器,否则上线之后老用户全部被当作新用户,可能重复点赞,计数立刻乱套。
我在改造某历史项目时遇到过一个很现实的问题:历史点赞数据存在数据库的like_log表里,总计约800万条。如果用单线程逐个SETBIT,13个哈希函数意味着要执行一亿多次Redis操作,会拖垮业务。最终我用了一个批量思路:把位数组在应用内存里算好,一次性写入Redis。具体做法是提前把800万条数据的哈希结果在Java里生成一个BitSet,然后把这个约23MB的位数组拆成适合Redis传输的分块写入,比如每块1MB。这样整个预热过程从几十分钟缩短到了几分钟,且不阻塞业务。
更进一步的方案是使用Redis的BITFIELD命令批量设置位,相比逐位SETBIT效率高得多,但BITFIELD的参数设计相对反人性,需要读者根据实际版本测试。我对这个方案的把握没有前者高,所以推荐的还是分块序列化写入。
6.3 分片Key的生命周期管理
一个内容从发布到下线,它的分片Key不是无限增长的。如果有好几百万个内容,每个内容64个分片Key,总量会非常庞大,但大多数内容是低热度的,甚至没有任何点赞。为了不让Redis里堆积无用的Key,我在同步任务里加了一个清理逻辑:如果某个内容连续多个同步周期(比如3个周期)的增量都是0,就把这个内容对应的分片Key从待同步列表里移除,并且在Redis里设置一个较短的过期时间(比如7天)。
过期时间这个细节要慎重。如果分片Key设置了过期时间,同步任务可能某个周期读取不到Key就跳过,但这段时间内仍然有用户的点赞增量没有落库,之后Key过期被删除,增量就永久丢失。所以我的处理是:只有确定这个内容已经完全没有新的点赞流量时,才允许删除。实践中用"连续N个周期增量为0"作为判定条件,并把删除操作单独前置到一个独立的清理任务里,与同步任务互不干扰。
6.4 异常恢复与补偿:同步任务挂了怎么办
同步任务本身也可能出问题。网络抖动、数据库不可用、代码上线失误,都可能导致同步任务长时间没有执行。这时候Redis里的分片Key会不断堆积增量,造成计数延迟拉大。我在实际项目中给同步任务加了一个简单的监控告警:如果某个内容的待同步增量超过阈值(比如1万),说明同步节奏跟不上了,触发告警的同时,把这个内容的分片Key临时提高到更高优先级的同步队列。
数据库不可用时的补偿策略同样要有。如果增量已经累加到总账Key,但数据库更新失败,不能直接丢弃增量。我采用的方案是把失败的contentId和增量落本地持久化存储(比如另一个可靠的存储或者本地磁盘的WAL文件),等数据库恢复后再重放。这个补偿通道保证了极端情况下也不会丢计数,最多是延迟。
6.5 Redis集群部署下的分片注意事项
如果Redis是集群模式,分片Key分散在多个节点上,聚合时的MGET跨节点会额外消耗网络RTT。我实测下来,64个分片Key分布在64个不同节点上的场景并不多见,实际集群规模一般在几十个节点,所以MGET跨节点的开销并不夸张。
真正要注意的是哈希槽计算的一致性。如果分片键里有大括号,比如like:{cnt}:{contentId},Redis集群会把大括号内的内容作为哈希键,这样可以强制把同一内容的分片Key集中到同一个节点。乍看利于聚合,却会让热点重新集中到单节点,失去分片的意义。所以不要在分片键里加花括号,让哈希自然分散,同时接受聚合时的跨节点读取。
7. 实测数据与选择边界:什么场景下这套方案不是最优
把这套方案实际搭建起来之后,我在模拟社区项目上做了一轮压测。机器配置不高级,8核16G的普通云主机,Redis单实例,模拟用户并发5000,单机QPS大概在2万左右。压力集中在一个热门话题的点赞时,分片方案和单Key方案的对比数据比较直观:
| 场景 | 单Key方案 | 64分片方案 |
|---|---|---|
| 写入P99延迟 | 8.2ms | 1.9ms |
| 数据库写压力(同步前) | 每秒直接 UPDATE | 稳定在每秒20次批量同步 |
| 热点故障时可扩展性 | 无法通过扩容改善 | 可随集群规模线性扩展 |
| 重复点赞确认成本 | 需要查询 like_log 表 | 布隆过滤器拦截+兜底查询 |
| 内存额外开销 | 低(仅计数) | 布隆过滤器约23MB + 分片Key少量 |
分片方案确实显著降低了写入延迟和数据库压力,但它有额外的运维成本:Key数量多、聚合逻辑和同步任务更复杂、布隆过滤器的生命周期需要维护。如果业务体量很小,一天只有几千次点赞,直接数据库UPDATE加唯一索引防重,比这套方案简单得多,完全不值得引入Redis。
反过来讲,当日活超过几十万、单日点赞量达到百万级时,这套方案的收益就非常明显。如果点赞量再上来一个量级,比如单日过亿,那么布隆过滤器的内存占用、分片聚合的网络开销、数据库批量更新的吞吐都会成为新的瓶颈,需要考虑改成更细的分片维度,或者把计数下沉到专门的统计数据库。我在项目中经常说"没有万能的方案,只有当前规模下最合适的取舍",这句话在这里恰好适用。
单场景之下,如果内容维度本身很少、用户量也不大,比如只有几百个内容N个用户,那分片和布隆过滤器都属于过度设计。要判断该不该上这套方案,只有一条标准:数据库当前的写入压力和连接池占用是不是已经到了影响正常业务的程度。如果是,就值得做;如果不是,精简方案更省心。