如何统计亿级用户的在线状态
如何统计亿级用户的在线状态
面试官您好!关于亿级用户在线状态统计这个问题,我会从核心挑战、方案演进、最终架构和关键优化四个层面来回答。
核心挑战 🎯
这个问题本质上不是简单的计数问题,而是高并发写入 + 低延迟读取 + 海量数据的综合挑战:
- 写入 QPS 极高:用户上下线事件每秒可达百万级
- 读取要求极低延迟:毫秒级返回在线人数
- 数据一致性要求:不能出现明显的在线人数偏差
- 成本可控:不能为了统计而投入过多资源
方案演进 📈
我会从最简单的方案开始,逐步演进到适合亿级用户的架构:
1️⃣ 初级方案:数据库计数法(万级用户)
// 伪代码
public void userOnline(Long userId) {
userDao.updateOnlineStatus(userId, true);
}
public long getOnlineCount() {
return userDao.countByOnlineStatus(true);
}问题:每次统计都要全表扫描,亿级数据下直接超时 ❌
2️⃣ 中级方案:Redis 计数器(十万级用户)
// 伪代码
public void userOnline(Long userId) {
redisTemplate.opsForValue().set("online:"+userId, "1", Duration.ofMinutes(30));
redisTemplate.opsForValue().increment("online:count");
}问题:
- 重复上线会导致计数器重复增加
- 下线事件丢失会导致计数器不准
- 单 Redis 实例无法支撑亿级用户 ❌
3️⃣ 高级方案:Redis Bitmap(百万级用户)
// 伪代码
public void userOnline(Long userId) {
redisTemplate.opsForValue().setBit("online:bitmap", userId, true);
}
public long getOnlineCount() {
return redisTemplate.execute((RedisCallback<Long>) conn -> conn.bitCount("online:bitmap".getBytes()));
}优势:1 亿用户仅需 12.5MB 内存(1 亿 bit = 12.5MB)
问题:单 Bitmap 在亿级用户下 bitCount 操作耗时仍会达到几十毫秒 ❌
最终架构:分片 Bitmap + 定时聚合 + 多级缓存 ✅
这是我在实际项目中使用过的、支撑过 3 亿 + 日活用户的方案:
核心实现细节 🔧
- 用户 ID 分片:将用户 ID 按模 10 分片到 10 个 Redis 实例
- 每个分片最多存储 1000 万用户的在线状态
- 每个分片的 bitCount 操作耗时 < 1ms
- 在线状态更新:
// 伪代码
public void userHeartbeat(Long userId) {
int shard = (int)(userId % 10);
redisTemplate.opsForValue().setBit("online:bitmap:"+shard, userId/10, true);
// 设置过期时间,自动清理离线用户
redisTemplate.expire("online:bitmap:"+shard, Duration.ofMinutes(30));
}- 定时聚合统计:
// 伪代码,每10秒执行一次
@Scheduled(fixedRate = 10000)
public void aggregateOnlineCount() {
long total = 0;
for (int i=0; i<10; i++) {
total += redisTemplate.opsForValue().bitCount("online:bitmap:"+i);
}
redisTemplate.opsForValue().set("online:total", total, Duration.ofSeconds(15));
}- 查询接口:
public long getOnlineCount() {
Long count = redisTemplate.opsForValue().get("online:total");
return count != null ? count : 0L;
}关键优化与边界处理 ⚡
| 问题 | 解决方案 | 效果 |
|---|---|---|
| 热点分片问题 | 采用一致性哈希替代简单取模 | 分片负载均衡度提升 90% |
| 实时性要求高 | 增加本地缓存,每 1 秒更新一次 | 查询延迟 < 1ms |
| 精确性要求高 | 关键业务场景使用 HyperLogLog 辅助验证 | 误差控制在 0.81% 以内 |
| Redis 宕机 | 主从复制 + 哨兵机制 | 故障自动切换,无数据丢失 |
| 历史数据统计 | 每 5 分钟快照一次 Bitmap 到 HBase | 支持任意时间点的在线人数回溯 |
扩展能力 🚀
- 分地区 / 分业务统计:增加维度前缀,如online:bitmap🇨🇳beijing:0
- 用户在线时长统计:结合 Redis Hash 记录用户最后上线时间
- 异常用户检测:统计单位时间内上下线次数,识别机器人
总结 📝
亿级用户在线状态统计的核心思想是:"分片处理 + 批量计算 + 结果缓存"
- 用 Bitmap 解决海量数据的存储问题
- 用分片解决单实例性能瓶颈
- 用定时聚合解决实时统计的性能问题
- 用多级缓存解决高并发查询问题
这个方案在实际生产环境中,支撑过 3 亿 + 日活、峰值 5000 万同时在线的场景,查询延迟稳定在 1ms 以内,完全满足互联网大厂的要求。
现场模拟面试
现场模拟面试
面试官 😊:
“同学你好,今天我们来聊一个场景设计题。假设现在我们有一个亿级用户的社交/IM系统,需要实时统计用户的在线状态,比如谁在线、当前总在线人数。要求支持高并发、低延迟,而且资源成本要可控。你会怎么设计?先说说你的整体思路。”
候选人 🤔:
“嗯……亿级用户的话,首先想到不能用传统数据库频繁写。在线状态是典型的读多写少但变更频繁的场景,可以用 Redis。大概思路是每个用户一个 key,上线 set,下线 del,统计在线数用 keys *?好像不太对……”
面试官 🧐:
“停,keys * 在大数据量下会直接阻塞 Redis,生产环境绝对是禁止的。你刚才说一个用户一个 key,假设有一亿用户,哪怕只有 1000 万同时在线,那就是 1000 万个 key,Redis 内存受得了吗?而且 key 过期管理也很麻烦。有没有更省内存的方案?”
候选人 💡:
“对,应该用 Bitmap(位图)。给每个用户分配一个数字 ID,上线就在对应的 bit 位置 1,下线置 0。统计在线总人数直接 BITCOUNT 命令,查询单个用户是否在线用 GETBIT。一亿用户才占用约 12.5MB 内存,非常省。”
面试官 😃:
“很好,方向对了!Bitmap 确实是这类问题的经典解法。那咱们继续深挖。你考虑过这三个实际痛点吗?
- 心跳与脏数据—— 用户直接断网或 APP 崩溃,没有发下线请求,Bitmap 里会一直「在线」。
- 多端登录—— 同一个用户手机、PC 同时在线,你只用一个 bit 怎么区分?
- 亿级用户 ID 不连续—— 如果用户 ID 是字符串或非连续数字,Bitmap 就不好使了。”
你看这三点怎么优化?
候选人 🤓:
“嗯…心跳问题,可以让客户端定时发心跳包(比如每 5 分钟),服务端收到后更新 Bitmap。但宕机的用户 Bitmap 还是 1,所以需要后台定时任务,比如每隔 10 分钟扫描 Bitmap,与「最后心跳时间表」对比,把超时的 bit 置 0。
多端登录的话,一个 bit 不够,可以拆成多个 bit,比如用 2bit 表示:00 离线,01 手机在线,10 PC 在线,11 多端在线。或者直接用另一个 Redis Set 存在线设备详情,Bitmap 只负责快速统计和判存。
ID 不连续问题,可以做一层「全局 ID 映射」,内部生成连续递增数字 ID,和业务 ID 一一对应,放在本地缓存或 Redis Hash 里。”
面试官 👍:
“不错,都对症下药了。那咱们再上升一个维度,说说整体架构和高可用。我画个简图,你边看边补充。”
📊 整体架构示意
图释:
- 心跳记录 用 Sorted Set(score=最后心跳时间戳),可以快速找到超时用户。
- Bitmap 负责在线状态判断和全局计数。
- 定时任务只扫 Sorted Set 中过期的用户,去 Bitmap 置 0,避免全量扫描。
面试官 👨🏫:
“那这个定时任务怎么扫才高效?如果亿级用户同时在线,心跳表也超大。”
候选人 ⚡:
“Sorted Set 可以用 ZRANGEBYSCORE 分批拉取 score 小于(当前时间 - 超时阈值)的用户,比如每次取 1000 条,更新 Bitmap 后再删掉 Sorted Set 中的记录。这样每次只处理真正过期的用户,压力可控。另外心跳写入用管道或异步批量写,减少 Redis 连接开销。”
面试官 🧑💻:
“不错。那如果要输出「当前在线用户列表」或「在线用户详情分页」,Bitmap 能扛吗?”
候选人 📃:
“Bitmap 其实不适合做列表分页,它只擅长判存和计数。如果需要在线用户列表,可以用 Redis Sorted Set 或 Set 存储在线用户 ID,分页用 ZSCAN 或 SSCAN。但这样内存会大一些,可以结合业务需要:用 Bitmap 做快速统计和大批量查询,用另一个轻量集合做少量列表展示,并设置 TTL + 心跳续期。”
面试官 🏁:
“Okay,最后来个总结。在亿级用户在线状态场景下,你的核心方案是?”
候选人 🎯:
“总结一下,一个中心、两个基本点:
- 中心:以 Redis Bitmap 作为核心存储,极致节省内存,提供 O(1) 查询和 bitcount 统计。
- 心跳维护:通过 Redis Sorted Set 记录最后心跳时间,定时任务增量清理超时 bit。
- ID 映射:用连续数字 ID 映射业务 ID,解决稀疏 ID 问题。
- 多端扩展:通过多 bit 位或辅助 Set 解决。
- 高可用:Redis Cluster 分片 + 哨兵,主从切换,避免单点。
整体是一个读多写少、内存友好、可线性扩展的在线状态系统。”
面试官 😄:
“非常清晰,技术点都踩到了。记得实际落地还要考虑网络抖动导致心跳误判(可加重试窗口)、Redis 大 key 问题(一亿 bit 的 Bitmap 不算大 key 但也要注意分片)、以及一致性(AP 模型,允许少量不一致)。好了,这题你通过了,回去等通知吧~” 🎉
场景设计题:如何统计亿级用户的在线状态?(补充版)🚀
面试官您好!我来补充一下这个方案的核心生产级代码和完整技术难点解决方案。
核心生产级代码实现 💻
7.1 Redis 配置类(技术亮点:自定义分片连接池)
@Configuration
@EnableCaching
public class RedisConfig {
// 技术亮点1:预定义10个分片连接池,避免运行时动态创建
@Bean("redisShard0")
public RedisTemplate<String, Object> redisShard0() {
return createRedisTemplate("192.168.1.10:6379");
}
@Bean("redisShard1")
public RedisTemplate<String, Object> redisShard1() {
return createRedisTemplate("192.168.1.11:6379");
}
// ... 省略其他8个分片的Bean定义
// 技术亮点2:统一创建模板,配置最优序列化方式
private RedisTemplate<String, Object> createRedisTemplate(String address) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(createConnectionFactory(address));
// 使用String序列化器,避免JDK序列化的性能开销
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(stringSerializer);
template.setHashValueSerializer(stringSerializer);
template.afterPropertiesSet();
return template;
}
private LettuceConnectionFactory createConnectionFactory(String address) {
String[] parts = address.split(":");
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName(parts[0]);
config.setPort(Integer.parseInt(parts[1]));
config.setDatabase(0);
// 技术亮点3:配置连接池参数,优化高并发场景
LettucePoolingClientConfiguration poolConfig = LettucePoolingClientConfiguration.builder()
.poolConfig(new GenericObjectPoolConfig<>() {{
setMaxTotal(200);
setMaxIdle(50);
setMinIdle(10);
setTestOnBorrow(true);
}})
.commandTimeout(Duration.ofMillis(100))
.build();
return new LettuceConnectionFactory(config, poolConfig);
}
}7.2 在线状态服务核心类(技术亮点:本地缓存 + 异步更新)
@Service
@Slf4j
public class OnlineStatusService {
private static final int SHARD_COUNT = 10;
private static final String BITMAP_PREFIX = "online:bitmap:";
private static final String TOTAL_COUNT_KEY = "online:total";
private static final Duration EXPIRE_TIME = Duration.ofMinutes(30);
// 技术亮点4:分片连接池数组,O(1)时间获取对应分片
@Autowired
private List<RedisTemplate<String, Object>> redisShards;
// 技术亮点5:本地缓存,减少Redis查询压力
private final AtomicLong localTotalCount = new AtomicLong(0);
private final AtomicLong lastUpdateTime = new AtomicLong(0);
private static final long LOCAL_CACHE_TTL = 1000; // 1秒
/**
* 用户心跳上报
* 技术亮点6:位操作+分片,极致性能
*/
public void userHeartbeat(Long userId) {
if (userId == null || userId <= 0) {
return;
}
try {
// 计算分片和偏移量
int shardIndex = (int) (userId % SHARD_COUNT);
long offset = userId / SHARD_COUNT;
RedisTemplate<String, Object> shard = redisShards.get(shardIndex);
String key = BITMAP_PREFIX + shardIndex;
// 技术亮点7:异步执行,不阻塞主业务线程
CompletableFuture.runAsync(() -> {
shard.opsForValue().setBit(key, offset, true);
shard.expire(key, EXPIRE_TIME);
});
} catch (Exception e) {
log.error("用户心跳上报失败 userId:{}", userId, e);
}
}
/**
* 获取在线总人数
* 技术亮点8:多级缓存,毫秒级响应
*/
public long getOnlineCount() {
long now = System.currentTimeMillis();
// 先查本地缓存
if (now - lastUpdateTime.get() < LOCAL_CACHE_TTL) {
return localTotalCount.get();
}
// 本地缓存过期,查Redis
try {
String countStr = (String) redisShards.get(0).opsForValue().get(TOTAL_COUNT_KEY);
long count = countStr != null ? Long.parseLong(countStr) : 0;
// 更新本地缓存
localTotalCount.set(count);
lastUpdateTime.set(now);
return count;
} catch (Exception e) {
log.error("获取在线人数失败", e);
// Redis异常时返回本地缓存值,保证可用性
return localTotalCount.get();
}
}
/**
* 定时聚合统计任务
* 技术亮点9:并行计算分片,提升聚合速度
*/
@Scheduled(fixedRate = 10000) // 每10秒执行一次
public void aggregateOnlineCount() {
try {
// 技术亮点10:并行流计算所有分片的bitCount
long total = IntStream.range(0, SHARD_COUNT)
.parallel()
.mapToLong(i -> {
try {
String key = BITMAP_PREFIX + i;
return redisShards.get(i).opsForValue().bitCount(key);
} catch (Exception e) {
log.error("分片{}统计失败", i, e);
return 0;
}
})
.sum();
// 写入结果缓存
redisShards.get(0).opsForValue().set(TOTAL_COUNT_KEY, String.valueOf(total), Duration.ofSeconds(15));
// 更新本地缓存
localTotalCount.set(total);
lastUpdateTime.set(System.currentTimeMillis());
log.info("在线人数统计完成: {}", total);
} catch (Exception e) {
log.error("在线人数聚合失败", e);
}
}
}7.3 网关层心跳拦截器(技术亮点:统一入口处理)
@Component
public class HeartbeatInterceptor implements HandlerInterceptor {
@Autowired
private OnlineStatusService onlineStatusService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 从请求头中获取用户ID
String userIdStr = request.getHeader("X-User-Id");
if (userIdStr != null && !userIdStr.isEmpty()) {
try {
Long userId = Long.parseLong(userIdStr);
onlineStatusService.userHeartbeat(userId);
} catch (NumberFormatException e) {
// 忽略无效用户ID
}
}
return true;
}
}完整技术难点与解决方案 🎯
| 技术难点 | 问题描述 | 解决方案 | 技术亮点 |
|---|---|---|---|
| 单 Redis 实例性能瓶颈 | 亿级用户下,单实例 bitCount 操作耗时 > 100ms,无法支撑高并发 | 分片 Bitmap 架构:将用户 ID 按模 N 分片到多个 Redis 实例 | 每个分片 bitCount 耗时 < 1ms,整体性能线性提升 |
| 重复上线导致计数不准 | 用户多次登录会导致计数器重复增加,下线事件丢失会导致计数虚高 | Bitmap 天然去重:同一用户无论上线多少次,对应位只会被设置为 1 | 无需额外处理重复事件,天生保证计数准确性 |
| 海量数据存储成本高 | 1 亿用户如果用 Hash 存储需要 GB 级内存 | Bitmap 极致压缩:1 亿用户仅需 12.5MB 内存 | 内存占用降低 99% 以上,成本可控 |
| 实时统计性能问题 | 每次查询都实时计算所有分片的 bitCount,QPS 超过 1000 就会压垮 Redis | 定时聚合 + 多级缓存:定时任务每 10 秒计算一次结果,写入缓存 | 查询延迟从几十毫秒降低到 < 1ms,QPS 提升 1000 倍 |
| 热点分片问题 | 简单取模分片可能导致某些分片负载过高 | 一致性哈希分片:将用户 ID 映射到哈希环上,动态调整分片 | 分片负载均衡度提升 90%,避免单点过热 |
| Redis 宕机数据丢失 | Redis 内存数据库,宕机后在线状态数据丢失 | 主从复制 + 哨兵机制 + 本地缓存兜底 | 故障自动切换,查询接口不受影响,恢复后数据自动同步 |
| 离线用户自动清理 | 用户异常下线不会触发下线事件,导致 Bitmap 中存在大量无效数据 | Bitmap 整体过期:每个分片设置 30 分钟过期时间 | 自动清理超过 30 分钟没有心跳的用户,无需额外清理任务 |
| 高并发写入压力 | 百万级 QPS 的心跳请求会压垮业务服务 | 网关层异步上报 + 消息队列削峰 | 业务服务解耦,峰值流量平滑处理 |
| 历史数据回溯 | 无法查看过去某个时间点的在线人数 | 定时快照 + HBase 存储:每 5 分钟快照一次 Bitmap 到 HBase | 支持任意时间点的在线人数回溯和趋势分析 |
| 分维度统计需求 | 需要按地区、业务线、设备类型等维度统计在线人数 | 维度前缀 + Bitmap 组合:如online:bitmap:cn:beijing:0 | 灵活支持多维度统计,性能与总人数统计一致 |
生产环境踩坑经验 ⚠️
- 不要使用 Redis Cluster 做分片:Redis Cluster 的跨节点操作性能很差,建议使用独立的 Redis 实例做分片
- 分片数量不要太多:建议 10-20 个分片,太多会增加聚合时间,太少无法解决性能瓶颈
- 过期时间要合理:建议设置为心跳间隔的 3 倍,如心跳 30 秒一次,过期时间 90 秒
- 不要在业务高峰期执行聚合任务:可以错开高峰期,或者降低聚合频率
- 监控每个分片的内存和 CPU 使用率:及时发现热点分片并调整
