一、背景
排行榜是ToC常见的业务功能,如、游戏排行榜、股票交易排行榜、文章热度排行榜等。
我的真实业务场景:游戏排行榜,需要支持百万级,下面从几种方案选型到实际生产验证的迭代过程,到最终上线的方案进行详细总结。
注意: 每种方案都是看自己的具体业务场景进行选择,通常从性能、成本、体验三个方面考虑
二、痛点
游戏排行榜并非是一个新功能,是生产上已经存在的功能。那我为什么还要做优化,肯定是存在隐患,我才要去处理。
什么隐患?
- 隐患一:排行榜近7天的榜单数据会存储在本地缓存,内存占比达到80%,濒临full gc
- 隐患二:应用启动会加载榜单数据到本地缓存时,并发读redis数据造成启动缓慢超时告警
因为是生产实际场景,不便贴数据统计数据图,大家能理解即可。
服务器、JVM配置如下:
- 服务器:4C8G的云服务器,总共24台
- JDK版本:jdk1.8.0_290
- JVM配置:
-Xms5324m -Xmx5324m -XX:MaxMetaspaceSize=256m -XX:MetaspaceSize=256m -XX:MaxDirectMemorySize=1000m -XX:ParallelGCThreads=2 -XX:ConcGCThreads=1 -XX:G1ConcRefinementThreads=1 -XX:G1HeapRegionSize=16m -XX:+UnlockExperimentalVMOptions -XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=50 -XX:G1MixedGCCountTarget=8 -XX:InitiatingHeapOccupancyPercent=50 -XX:+DisableExplicitGC -XX:-UseLargePages -XX:SoftRefLRUPolicyMSPerMB=0 -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/export/Logs/heapdump-%t.hprof -XX:OnOutOfMemoryError=/bin/sh -c "echo OOM occurred at $(date), PID: $1 >> /export/Logs/oom-error.log" -Xloggc:/export/Logs/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=100M -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai
| 参数 | 参数含义说明 |
|---|---|
-Xms5324m | JVM 初始堆内存大小,设置为 5324MB,与 -Xmx相同以避免堆内存动态扩容带来的性能波动 |
-Xmx5324m | JVM 最大堆内存大小,设置为 5324MB |
-XX:MaxMetaspaceSize=256m | 元空间最大容量,设置为 256MB,元空间用于存放类元数据 |
-XX:MetaspaceSize=256m | 元空间初始容量,设置为 256MB,达到该值时触发元空间垃圾回收 |
-XX:MaxDirectMemorySize=1000m | 最大直接内存大小,设置为 1000MB,直接内存不受堆内存管理 |
-XX:ParallelGCThreads=2 | 并行垃圾回收器的线程数,设置为 2,用于并行回收阶段 |
-XX:ConcGCThreads=1 | 并发垃圾回收器的线程数,设置为 1,用于并发标记等阶段 |
-XX:G1ConcRefinementThreads=1 | G1 垃圾回收器的并发细化线程数,设置为 1,用于更新 Remembered Set |
-XX:G1HeapRegionSize=16m | G1 堆内存的 Region 大小,设置为 16MB,Region 是 G1 的内存分配单元 |
-XX:+UnlockExperimentalVMOptions | 解锁实验性的 JVM 参数,允许使用一些未正式发布的参数 |
-XX:G1NewSizePercent=20 | G1 新生代最小占堆内存的百分比,设置为 20% |
-XX:G1MaxNewSizePercent=50 | G1 新生代最大占堆内存的百分比,设置为 50% |
-XX:G1MixedGCCountTarget=8 | G1 混合垃圾回收的目标次数,设置为 8 次,用于回收老年代的 Region |
-XX:InitiatingHeapOccupancyPercent=50 | 触发 G1 并发标记周期的堆内存占用阈值,设置为 50% |
-XX:+DisableExplicitGC | 禁用代码中显式调用的 System.gc(),避免触发 Full GC |
-XX:-UseLargePages | 禁用大页内存,大页内存可提升性能但可能增加内存占用 |
-XX:SoftRefLRUPolicyMSPerMB=0 | 软引用的存活时间策略,设置为 0 表示内存不足时立即回收软引用对象 |
-XX:+UseG1GC | 启用 G1 垃圾回收器,G1 是面向服务端的低停顿垃圾回收器 |
-XX:+HeapDumpOnOutOfMemoryError | 发生 OutOfMemoryError时自动生成堆转储文件 |
-XX:HeapDumpPath=/export/Logs/heapdump-%t.hprof | 堆转储文件的保存路径,%t表示自动添加时间戳 |
-XX:OnOutOfMemoryError=/bin/sh -c "echo OOM发生于$(date),进程PID:$1 >> /export/Logs/oom-error.log" | 发生 OutOfMemoryError时执行的自定义脚本,用于记录 OOM 事件和进程 PID(建议生产环境改用英文输出避免编码问题) |
-Xloggc:/export/Logs/gc-%t.log | 指定 GC 日志的输出路径,%t表示自动添加时间戳 |
-XX:+PrintGCDetails | 打印详细的 GC 日志信息,包括回收的内存大小、耗时等 |
-XX:+PrintGCDateStamps | 在 GC 日志中打印 GC 发生的绝对时间戳(如 yyyy-MM-dd HH:mm:ss) |
-XX:+PrintHeapAtGC | 在 GC 前后打印堆内存的分布情况,便于分析内存变化 |
-XX:+UseGCLogFileRotation | 启用 GC 日志文件的轮转功能,防止单文件过大 |
-XX:NumberOfGCLogFiles=5 | GC 日志文件的最大保留数量,设置为 5 个 |
-XX:GCLogFileSize=100M | 单个 GC 日志文件的大小阈值,达到 100MB 时触发轮转 |
三、实现方案
概念词解释:
- 分片:zset命令的一个缓存键
- 持久化:除了方案五,都没有进行数据持久化,大家看自己场景是否兜底
方案一:单zset命令
一个redis的zset命令天生具备排序能力,哪怕是分数相同也会根据field的字典hash进行排序。
为什么不适用?公司内部的缓存限制不能存储超过5000条的数据,不适用我们这个百万级的场景。5000不可以调整,这是红线,对于大key本身就是一种隐患!
方案二:无序分片+定时任务+本地缓存
这个方案就是优化排行榜之前的方案。
缺点即痛点章节提到的问题风险。
我解释下该方案:
- 无序分片:限制每个分片容纳3000条,超过3000新增分片,依次递增
- 定时任务:将所有分片数据从redis读取,本地进行全局降序排序
- 本地缓存:采用guava进行本地缓存存储
使用场景:这个如果是机器配置本地内存在16G以上,其实还可以。可支撑30万用户左右。
方案三:原榜单 + 快照榜单(MQ + 分布式锁)
本方案采用原榜单 + 快照榜单双层架构,结合 MQ 异步计算、分布式锁保障数据一致性,通过分片存储(单分片 3000 用户)实现榜单高效读写,同时保证榜单全局降序排列,核心解决分片存储与全局有序的适配问题。
一、原榜单
原榜单基于 Redis ZSet 实现用户数据的实时写入与更新,为快照榜单提供基础数据,分片规则固定为单分片容纳 3000 用户:
- 新用户数据变更: 直接写入 Redis ZSet,若当前分片达到 3000 用户上限,自动新增分片;
- 存量用户数据变更: 直接更新对应分片内的用户分数即可。
二、快照榜单
依托 MQ 异步触发快照榜单计算,完成用户分片的动态调整,保障榜单全局降序,全程通过幂等消费和分布式锁规避数据不一致问题。
- MQ 异步触发:用户数据变更时,发送含id(幂等标识)、userId、score的消息至 MQ;消费端通过 Redis 存储消费 id 实现幂等,避免重复处理;
- 分片跃迁与驱逐:消费消息后计算用户分片归属(分片编号越小,分数层级越高),需读取当前用户所在分片编号更小的所有分片,判断目标跃迁分片;若用户迁入后目标分片用户数超 3000 上限,将该分片超过 3000 部分的低分数据向下驱逐(如分片用户数为 3100,则驱逐 100 个低分用户);若被驱逐的分片接收数据后仍超 3000 上限,继续对其执行低分数据向下驱逐,直至所有涉及的分片用户数均≤3000;
- 分布式锁保障一致性:用户分片迁移阶段加用户级分布式锁(持锁 30 秒),防止同一用户被多次迁移导致多分片重复存储;持锁期间仅执行「先向目标分片添加用户数据,再从原分片删除用户数据」两个操作,处理完成后即时释放锁。
三、兜底机制:定时任务校准分片归属
为避免高分用户因异常停留在高编号低分级分片,配置定时任务定期检测分片数据,对归属错误的高分用户进行分片调整,确保其归至对应分数层级的正确分片。
处理流程图:
使用场景:数据变更量不能太高,比如分数变化在1000TPS以下,redis集群节点要充足,防止OPS操作数过高(读写量高,因为是实时计算排序)
★方案四:原榜单 + 快照榜单 + 定时任务 + 版本号
本方案采用双层架构设计,分为实时原榜单与定时快照榜单,均基于Redis实现,统一遵循单分片容纳3000用户的固定分片规则,兼顾数据实时更新与查询效率。
一、实时原榜单
与方案三一致
二、快照榜单
快照榜单通过定时任务生成,基于实时原榜单全量数据进行全局排序后持久化至Redis,采用版本号管控确保查询准确性,同时根据生成时间设置差异化过期策略,具体实现如下:
- 定时生成逻辑
定时任务每10分钟执行一次,执行时间控制在每日23:00前;任务执行时,先全量读取实时原榜单所有ZSet分片的用户数据,在内存中完成全局降序排序后,按照单分片3000用户的规则重新分片,依次写入Redis ZSet(与实时原榜单数据结构保持一致),分片写满则自动新增。
- 版本号管控
系统在Redis中维护一个String类型的全局递增版本号(初始值为1),每次快照榜单全部分片写入完成后,对该版本号进行原子性自增更新。外部系统读取快照榜单时,需先获取该版本号,再根据版本号定位至最新快照的ZSet分片集群,避免读取过期快照数据。版本号的更新需要在本次定时任务需要保存的所有数据无异常保存成功才可以更新版本号。
- 数据存储要求
快照生成后,需保存以下三类数据:
(1) 分片编号:使用zset保存分片编号,field和score均为编号
(2) 用户详情数据:每个用户单独以Redis String类型存储,格式为JSON字符串,包含用户名次、所属分片编号、榜单分数三个核心字段;
(3) 版本号数据:Redis String类型,存储当前最新快照的递增版本号。
- 差异化过期策略
快照榜单数据的过期时间按生成时段区分:
(1) 每日23:00前生成的快照,过期时间设置为30分钟;
(2) 每日23:00后生成的快照,过期时间设置为10天,用于夜间及后续长期留存查询。
流程图:
使用场景:需要一台高配置机器(8C24G),支持百万级用户,redis集群节点多,主要占用内存多
缺点:占用5倍的redis内存(23点之后保存,执行6次)
这个是我的最终实选择的方案
方案五:hive + flink定时计算 + 快照榜单 + 版本号
本方案需搭建一套从 Hive 数仓到 Redis 缓存的完整数据处理流程,核心围绕实时数据更新、版本号统一管控、数据规范存储、差异化过期策略落地,具体方案如下:
一、核心数据流转流程
- Hive 数仓数据写入:将业务产生的新增、更新数据实时同步至 Hive 数仓,保证数仓数据与业务数据一致,为后续榜单排序提供完整的数据源;
- Flink 定时任务执行:配置定时任务触发 Flink 作业,由 Flink 从 Hive 数仓读取数据后,按业务指定的排序规则(如榜单分数)完成实时排序;
- Redis 缓存落地:将 Flink 排序后的榜单数据按既定规则写入 Redis 缓存,完成从数仓数据读取、计算排序到缓存落地的全流程处理。
二、快照榜单
与方案四一致
流程图:
使用场景:具备大数据基建能力,承接千万级用户榜单,redis集群充足