外观
ZSet + Bitmap 组合实战:一套代码搞定分布式会话、排行榜与签到
「在线人数统计、活动排行榜、每日签到」是社区与电商类应用最常见的三件套,大量团队的实现是三张数据库表加定时任务聚合:在线表每次心跳 UPDATE,榜单排序靠 SQL ORDER BY,签到记录逐行 INSERT。日活几十万以内这套还能撑,日活过千万后,心跳表写放大、榜单实时排序超时、签到表一个月几十亿行,三座山同时压过来。而这三个需求在 Redis 里各有一条量身定做的原语:ZSet 负责按分数排序的集合,Bitmap 负责按位存储的状态矩阵,两者组合起来一套代码就能把三件事全办了。这篇文章按方案对比、会话、榜单、签到的顺序展开,所有代码可直接运行。
方案对比:为什么是这个组合拳
先把候选方案的账算清楚:
| 方案 | 在线会话 | 排行榜 | 签到 | 结论 |
|---|---|---|---|---|
| 纯数据库 | 心跳写放大严重 | ORDER BY 实时排序慢 | 行数爆炸 | 低日活可用 |
| 全用 ZSet | 在线判定可行 | 天然契合 | 签到记录变成员集合,内存浪费 | 会话榜单可用 |
| 全用 Bitmap | 只有秒级粒度难做 | 无法按分数排序 | 极致省内存 | 只适合状态位 |
| ZSet + Bitmap 组合 | ZSet 心跳续期 | ZSet score 编码 | Bitmap 按位记录 | 三者各得其所 |
选择逻辑很清晰:需要「按时间或分数排序 + 范围统计」的,交给 ZSet;需要「海量对象的天级布尔状态」的,交给 Bitmap。ZSet 一个亿级成员榜单轻松上百 MB,而 Bitmap 记录一亿用户某天的签到状态只需约 12MB——按数据形态分配结构,才能既快又省。
分布式会话:ZSet 心跳续期与滚动清理
会话管理的模型是「成员是用户 ID,分数是最后一次活跃时间戳」。每次心跳 ZADD 覆盖分数实现续期,在线判定就是「分数在最近 N 秒内」的范围统计:
java
// 心跳:每次活跃刷新 score 为当前毫秒时间戳,300 秒无心跳视为离线
public void heartbeat(long userId) {
redis.opsForZSet().add("online:app1", String.valueOf(userId),
System.currentTimeMillis());
}
// 在线人数:统计最近 300 秒有心跳的成员数,一条命令完成
public long onlineCount() {
return redis.opsForZSet().count("online:app1",
System.currentTimeMillis() - 300_000, Double.MAX_VALUE);
}
// 定时任务:清理过期会话,防止 ZSet 随历史用户无限膨胀
public void evictExpiredSessions() {
redis.opsForZSet().removeRangeByScore("online:app1", 0,
System.currentTimeMillis() - 300_000);
}三个工程细节:心跳间隔取在线判定窗口的三分之一到二分之一(如窗口 300 秒、心跳 60 到 100 秒),既保证不误判离线又控制写频率;removeRangeByScore 清理任务要分批执行,一次删除百万成员同样会拉长命令耗时;如果业务只需要在线「人数」而不需要在线「名单」,可以退化为更轻的方案——每个用户一个短 TTL 的 String key 计数,或直接用 Bitmap 记录分钟级活跃位图。
排行榜:score 编码实现同分先到先得
ZSet 默认按分数排序,但同分成员的次序取决于成员的字典序,业务上「同分时先达到者排前面」需要自己编码。方案是把分数放高位、倒序时间放低位:score = 总分 × 1e7 +(周期总秒数 − 已耗秒数)。分数相同且越早达到,后半段数值越大,排名越靠前。加分动作必须原子,用 Lua 一次完成解码、加分、编码:
java
// Lua:读取旧 score 解码总分,加上本次加分后重新编码,原子完成
private static final DefaultRedisScript<Long> RANK_ADD = new DefaultRedisScript<>("""
local cur = redis.call('ZSCORE', KEYS[1], ARGV[1])
local oldPoints = 0
if cur then
oldPoints = math.floor(tonumber(cur) / 10000000)
end
local newPoints = oldPoints + tonumber(ARGV[2])
local elapsed = tonumber(ARGV[3]) - tonumber(ARGV[4])
local score = newPoints * 10000000 + (tonumber(ARGV[5]) - elapsed)
redis.call('ZADD', KEYS[1], score, ARGV[1])
return score
""", Long.class);
public void addScore(long userId, int points, long periodStartTs, long periodSeconds) {
redis.execute(RANK_ADD, List.of("rank:202610"),
String.valueOf(userId), String.valueOf(points),
String.valueOf(Instant.now().getEpochSecond()),
String.valueOf(periodStartTs), String.valueOf(periodSeconds));
}编码设计经过精度验证:分数上限设为 1000 万(7 位数),月榜周期最长约 2678400 秒(也是 7 位数内),拼接后的 score 不超过 1e14,远小于 double 类型 2 的 53 次方(约 9e15)的精确整数上限,解码用 math.floor(score / 1e7) 取回真实分数不会失真。展示侧的 Top 榜单与个人排名:
java
// Top10:ZREVRANGE 降序取前 10,解码还原真实分数
public List<RankVO> top10() {
Set<ZSetOperations.TypedTuple<String>> tuples =
redis.opsForZSet().reverseRangeWithScores("rank:202610", 0, 9);
return tuples.stream().map(t -> new RankVO(
Long.parseLong(t.getValue()),
(long) Math.floor(t.getScore() / 1e7))).toList();
}
// 我的排名:ZREVRANK 从 0 开始计数,前端展示时加 1
public long myRank(long userId) {
Long rank = redis.opsForZSet().reverseRank("rank:202610", String.valueOf(userId));
return rank == null ? -1 : rank + 1;
}百万成员大榜只需记住一条纪律:永远不执行 ZRANGE 0 -1 这类全量拉取,TopN 用 reverseRangeWithScores 带偏移分页,冷数据统计用 ZSCAN 游标分批遍历。
签到与连续活跃:Bitmap 的位运算红利
签到是 Bitmap 的教科书场景:每个用户每个月一个 key,offset 取「当月第几天减一」,一整月的签到状态只占 31 个比特:
java
// 签到:key 按月,1 号对应 offset 0,月底后多留半月供历史查询
public void checkIn(long userId, LocalDate date) {
String key = "checkin:%d:%s".formatted(userId,
date.format(DateTimeFormatter.ofPattern("yyyyMM")));
redis.opsForValue().setBit(key, date.getDayOfMonth() - 1, true);
redis.expire(key, Duration.ofDays(45));
}
// 本月签到天数:BITCOUNT 直接数 1 的个数
public long monthCheckInDays(long userId, YearMonth month) {
String key = "checkin:%d:%s".formatted(userId,
month.format(DateTimeFormatter.ofPattern("yyyyMM")));
Long bits = redis.execute((RedisCallback<Long>) con ->
con.stringCommands().bitCount(key.getBytes(StandardCharsets.UTF_8)));
return bits == null ? 0 : bits;
}
// 连续签到:从今天往前逐位检查,断档即止(当天未签不中断回看)
public long consecutiveDays(long userId, LocalDate today) {
long days = 0;
LocalDate cursor = today;
while (!cursor.isBefore(today.minusDays(60))) {
String key = "checkin:%d:%s".formatted(userId,
cursor.format(DateTimeFormatter.ofPattern("yyyyMM")));
Boolean bit = redis.opsForValue().getBit(key, cursor.getDayOfMonth() - 1);
if (Boolean.TRUE.equals(bit)) {
days++;
} else if (!cursor.equals(today)) {
break;
}
cursor = cursor.minusDays(1);
}
return days;
}Bitmap 的进阶价值在 BITOP 位运算:把每天的全站活跃用户各存一个 bitmap,对连续三天的三个 bitmap 做 AND,得到的交集就是「连续活跃三天」的用户群,一条命令完成留存分析;做 OR 则得到三天内任一天活跃的并集。这类位运算在应用层做要拉全量数据循环,在 Redis 里是 O(N/8) 的原生操作,注意结果集会新建一个 key,运算后按需设置过期。
避坑总结
- score 拼接必须核算精度上限。double 只有 53 位精确整数空间(约 9e15),分数与时间两段拼接后的总量级必须留足余量,否则会出现「大分数排序莫名并列」的诡异现象;上线前用极限分值做一次解码回归。
- 稀疏 offset 是 Bitmap 的隐形炸弹。SETBIT 的 offset 是真实偏移量,用户 ID 直接当 offset 时,一个 ID 上亿的新用户会把中间的空洞内存一次性分配出来;先用自增 ID 或哈希映射把 offset 压实,再落位图。
- 会话与榜单的清理都要分批。removeRangeByScore 一次删除百万成员、ZREMRANGEBYSCORE 大范围清榜,都会造成命令级阻塞,循环小批量删除并夹带睡眠,是这类任务的固定写法。
- 周期榜单的边界与时区要写进设计文档。月榜按 UTC 还是东八区切分、重置时旧榜归档还是直接过期,不提前定死,日后必出对不上账的客诉;建议 key 带周期后缀并显式设置过期时间。
- Lua 里的空值与解码要防御。ZSCORE 对不存在成员返回 nil,Lua 判断写错会把 nil 当数字导致脚本报错;榜单成员被清理后的重新加分路径,也要保证解码逻辑幂等。
写在最后
ZSet 与 Bitmap 的组合之所以值得沉淀成团队模式,在于两者覆盖了「有序统计」与「状态矩阵」两大高频原语,会话、榜单、签到只是它们的三个化身,同样的结构还能支撑延迟任务、留存分析、连续登录奖励等一批变体需求。用对结构的收益是复利的:内存省下来的每一 MB、命令原子化省下的每一次锁,都会随用户量放大。下次设计这些功能时,先想想这套组合拳能不能直接出招。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。