redis记录用户在线状态+活跃度

发布于:2025-02-14 ⋅ 阅读:(118) ⋅ 点赞:(0)

1.记录用户在线状态

redis的Bitmap记录用户在线状态

  • 使用一个大的Bitmap,每个bit位对应一个用户ID
  • bit值1表示在线,0表示离线
  • 用户ID与bit位的映射关系: bit位置 = 用户ID % bitmap容量

具体实现:

# 用户上线时,设置对应bit为1
SETBIT online_users {user_id} 1

# 用户下线时,设置对应bit为0  
SETBIT online_users {user_id} 0

# 判断用户是否在线
GETBIT online_users {user_id}

# 获取当前在线用户数量
BITCOUNT online_users

# 批量获取在线用户列表
# 每次获取一个字节(8位)的数据
for i in range(0, max_user_id, 8):
    byte = GETRANGE online_users i i+7
    # 解析byte中的每一位,位为1的即为在线用户

1.1优化策略

按业务分片

# 可以按照业务线划分不同的bitmap
SETBIT online_users:game {user_id} 1
SETBIT online_users:chat {user_id} 1

# 统计特定业务的在线用户
BITCOUNT online_users:game

时间分片

# 按天记录用户在线状态
SETBIT online_users:{date} {user_id} 1

# 统计最近7天的日活用户(使用BITOP OR合并)
BITOP OR online_users_7days 
    online_users:20240212
    online_users:20240211
    ...
    online_users:20240206

容量优化

  • 每个bitmap最大512MB,可存储40亿用户状态
  • 当用户量超大时,可以分片存储:
# 用户ID按范围分片
SETBIT online_users:0 {user_id % 1000000} 1
SETBIT online_users:1 {user_id % 1000000} 1

1.2java代码示例

// 1. 配置类
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        return template;
    }
}

// 2. 用户在线状态服务
@Service
@Slf4j
public class UserOnlineStatusService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String ONLINE_KEY_PREFIX = "user:online:";
    private static final int EXPIRE_DAYS = 7; // 数据保留7天
    
    /**
     * 设置用户在线
     */
    public void setUserOnline(Long userId, String bizType) {
        try {
            String key = buildKey(bizType, LocalDate.now());
            redisTemplate.opsForValue().setBit(key, userId, true);
            // 设置过期时间
            redisTemplate.expire(key, EXPIRE_DAYS, TimeUnit.DAYS);
        } catch (Exception e) {
            log.error("Failed to set user online status: userId={}, bizType={}", userId, bizType, e);
            throw new RuntimeException("Failed to set user online status", e);
        }
    }
    
    /**
     * 设置用户离线
     */
    public void setUserOffline(Long userId, String bizType) {
        try {
            String key = buildKey(bizType, LocalDate.now());
            redisTemplate.opsForValue().setBit(key, userId, false);
        } catch (Exception e) {
            log.error("Failed to set user offline status: userId={}, bizType={}", userId, bizType, e);
            throw new RuntimeException("Failed to set user offline status", e);
        }
    }
    
    /**
     * 批量设置用户在线状态
     */
    public void batchSetUserOnline(List<Long> userIds, String bizType) {
        String key = buildKey(bizType, LocalDate.now());
        try {
            for (Long userId : userIds) {
                redisTemplate.opsForValue().setBit(key, userId, true);
            }
            redisTemplate.expire(key, EXPIRE_DAYS, TimeUnit.DAYS);
        } catch (Exception e) {
            log.error("Failed to batch set user online status: userCount={}, bizType={}", 
                userIds.size(), bizType, e);
            throw new RuntimeException("Failed to batch set user online status", e);
        }
    }
    
    /**
     * 判断用户是否在线
     */
    public boolean isUserOnline(Long userId, String bizType) {
        try {
            String key = buildKey(bizType, LocalDate.now());
            return Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, userId));
        } catch (Exception e) {
            log.error("Failed to check user online status: userId={}, bizType={}", userId, bizType, e);
            throw new RuntimeException("Failed to check user online status", e);
        }
    }
    
    /**
     * 获取当前在线用户数量
     */
    public long getOnlineUserCount(String bizType) {
        try {
            String key = buildKey(bizType, LocalDate.now());
            return redisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes()));
        } catch (Exception e) {
            log.error("Failed to get online user count: bizType={}", bizType, e);
            throw new RuntimeException("Failed to get online user count", e);
        }
    }
    
    /**
     * 获取指定用户列表中的在线用户数量
     */
    public long getOnlineUserCount(List<Long> userIds, String bizType) {
        String key = buildKey(bizType, LocalDate.now());
        long count = 0;
        try {
            for (Long userId : userIds) {
                if (Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, userId))) {
                    count++;
                }
            }
            return count;
        } catch (Exception e) {
            log.error("Failed to get online user count for specific users: userCount={}, bizType={}", 
                userIds.size(), bizType, e);
            throw new RuntimeException("Failed to get online user count", e);
        }
    }
    
    /**
     * 获取在线用户列表
     * @param start 起始用户ID
     * @param end 结束用户ID
     */
    public List<Long> getOnlineUsers(String bizType, long start, long end) {
        List<Long> onlineUsers = new ArrayList<>();
        String key = buildKey(bizType, LocalDate.now());
        try {
            for (long userId = start; userId <= end; userId++) {
                if (Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, userId))) {
                    onlineUsers.add(userId);
                }
            }
            return onlineUsers;
        } catch (Exception e) {
            log.error("Failed to get online users: bizType={}, start={}, end={}", 
                bizType, start, end, e);
            throw new RuntimeException("Failed to get online users", e);
        }
    }
    
    /**
     * 统计今日在线过的用户数量(活跃用户)
     */
    public long getDailyActiveUserCount(String bizType) {
        try {
            String key = buildKey(bizType, LocalDate.now());
            return redisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes()));
        } catch (Exception e) {
            log.error("Failed to get daily active user count: bizType={}", bizType, e);
            throw new RuntimeException("Failed to get daily active user count", e);
        }
    }
    
    /**
     * 统计指定日期范围内的活跃用户数量(去重)
     */
    public long getActiveUserCountByDateRange(String bizType, LocalDate startDate, LocalDate endDate) {
        try {
            List<String> keys = new ArrayList<>();
            LocalDate currentDate = startDate;
            while (!currentDate.isAfter(endDate)) {
                keys.add(buildKey(bizType, currentDate));
                currentDate = currentDate.plusDays(1);
            }
            
            // 使用OR操作合并多个bitmap
            String destKey = String.format("%s:temp:%s:%s", 
                ONLINE_KEY_PREFIX, startDate, endDate);
            redisTemplate.execute((RedisCallback<Object>) con -> {
                byte[][] byteKeys = keys.stream()
                    .map(String::getBytes)
                    .toArray(byte[][]::new);
                con.bitOp(RedisStringCommands.BitOperation.OR, 
                    destKey.getBytes(), byteKeys);
                return null;
            });
            
            // 统计合并后的bitmap中1的数量
            Long count = redisTemplate.execute(
                (RedisCallback<Long>) con -> con.bitCount(destKey.getBytes()));
            
            // 删除临时key
            redisTemplate.delete(destKey);
            
            return count != null ? count : 0;
        } catch (Exception e) {
            log.error("Failed to get active user count by date range: bizType={}, startDate={}, endDate={}", 
                bizType, startDate, endDate, e);
            throw new RuntimeException("Failed to get active user count by date range", e);
        }
    }
    
    private String buildKey(String bizType, LocalDate date) {
        return ONLINE_KEY_PREFIX + bizType + ":" + date.format(DateTimeFormatter.ISO_DATE);
    }
}

// 3. Controller层示例
@RestController
@RequestMapping("/api/user/status")
@Slf4j
public class UserOnlineStatusController {
    @Autowired
    private UserOnlineStatusService onlineStatusService;
    
    @PostMapping("/online")
    public ResponseEntity<String> setUserOnline(
            @RequestParam Long userId,
            @RequestParam String bizType) {
        onlineStatusService.setUserOnline(userId, bizType);
        return ResponseEntity.ok("Success");
    }
    
    @PostMapping("/offline")
    public ResponseEntity<String> setUserOffline(
            @RequestParam Long userId,
            @RequestParam String bizType) {
        onlineStatusService.setUserOffline(userId, bizType);
        return ResponseEntity.ok("Success");
    }
    
    @GetMapping("/check")
    public ResponseEntity<Boolean> checkUserOnline(
            @RequestParam Long userId,
            @RequestParam String bizType) {
        boolean isOnline = onlineStatusService.isUserOnline(userId, bizType);
        return ResponseEntity.ok(isOnline);
    }
    
    @GetMapping("/count")
    public ResponseEntity<Map<String, Object>> getOnlineCount(
            @RequestParam String bizType) {
        Map<String, Object> result = new HashMap<>();
        result.put("bizType", bizType);
        result.put("onlineCount", onlineStatusService.getOnlineUserCount(bizType));
        result.put("timestamp", LocalDateTime.now());
        return ResponseEntity.ok(result);
    }
    
    @GetMapping("/active/range")
    public ResponseEntity<Map<String, Object>> getActiveUsersByDateRange(
            @RequestParam String bizType,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
        Map<String, Object> result = new HashMap<>();
        result.put("bizType", bizType);
        result.put("startDate", startDate);
        result.put("endDate", endDate);
        result.put("activeUsers", 
            onlineStatusService.getActiveUserCountByDateRange(bizType, startDate, endDate));
        return ResponseEntity.ok(result);
    }
}

// 4. 测试类
@SpringBootTest
class UserOnlineStatusServiceTest {
    @Autowired
    private UserOnlineStatusService onlineStatusService;
    
    @Test
    void testUserOnlineStatus() {
        String bizType = "test";
        Long userId = 1L;
        
        // 设置用户在线
        onlineStatusService.setUserOnline(userId, bizType);
        assertTrue(onlineStatusService.isUserOnline(userId, bizType));
        
        // 设置用户离线
        onlineStatusService.setUserOffline(userId, bizType);
        assertFalse(onlineStatusService.isUserOnline(userId, bizType));
    }
    
    @Test
    void testBatchOperation() {
        String bizType = "test";
        List<Long> userIds = Arrays.asList(1L, 2L, 3L, 4L, 5L);
        
        // 批量设置在线
        onlineStatusService.batchSetUserOnline(userIds, bizType);
        
        // 验证在线数量
        assertEquals(5, onlineStatusService.getOnlineUserCount(bizType));
        
        // 验证具体用户在线状态
        for (Long userId : userIds) {
            assertTrue(onlineStatusService.isUserOnline(userId, bizType));
        }
    }
}

1.3注意事项

  • Bitmap适合用户ID比较连续的场景
  • 如果用户ID不连续,可能会浪费一些空间
  • 对于大规模系统,建议按业务类型分片
  • 重要操作需要添加监控和告警
  • 考虑添加缓存层减少Redis访问

 

 

2.HyperLogLog统计活跃度

2.1使用场景:

  • 日活跃用户(DAU)
  • 周活跃用户(WAU)
  • 月活跃用户(MAU)
  • 页面/功能的独立访客数(UV)

2.2基本实现:

# 记录用户访问
PFADD daily_active:{date} {user_id}

# 获取当日活跃用户数
PFCOUNT daily_active:{date}

# 合并多天数据得到周活
PFMERGE weekly_active 
    daily_active:20240212
    daily_active:20240211
    ...
    daily_active:20240206

2.3高级应用:

多维度活跃度分析

# 按照不同维度记录
PFADD active:game:{date} {user_id}
PFADD active:shop:{date} {user_id}
PFADD active:social:{date} {user_id}

# 统计用户在各个维度的活跃度
PFCOUNT active:game:{date}
PFCOUNT active:shop:{date}
PFCOUNT active:social:{date}

活跃度分层: 

# 记录不同活跃度的用户
PFADD active:level:high:{date} {user_id}  # 高活跃用户
PFADD active:level:medium:{date} {user_id} # 中活跃用户
PFADD active:level:low:{date} {user_id}   # 低活跃用户

# 统计各层级用户数
PFCOUNT active:level:high:{date}

漏斗分析:

# 记录用户在不同阶段的行为
PFADD funnel:visit:{date} {user_id}    # 访问
PFADD funnel:browse:{date} {user_id}   # 浏览
PFADD funnel:cart:{date} {user_id}     # 加购
PFADD funnel:order:{date} {user_id}    # 下单
PFADD funnel:pay:{date} {user_id}      # 支付

# 分析转化率
visit_count = PFCOUNT funnel:visit:{date}
pay_count = PFCOUNT funnel:pay:{date}
conversion_rate = pay_count / visit_count

 

2.4 性能与准确性:

HyperLogLog的优点:

  • 空间效率极高,每个HyperLogLog仅需12KB内存
  • 计数效率高,不随数据量增加而降低性能
  • 可以合并统计,支持分布式场景

需要注意的限制:

  • 有0.81%的标准误差
  • 不支持删除单个元素
  • 只能统计基数,不能获取实际的元素内容

最佳实践建议:

合理设置过期时间

# 设置数据过期时间
PFADD daily_active:{date} {user_id}
EXPIRE daily_active:{date} 30 * 86400  # 30天后过期

配合其他数据类型使用:

# 使用Set保存详细的用户列表(当需要少量精确数据时)
SADD active_users:{date} {user_id}

# 使用HyperLogLog统计大量数据
PFADD active_count:{date} {user_id}

 批量统计优化:

# 使用pipeline批量写入
pipeline.pfadd(f"active:{date}", user_id1)
pipeline.pfadd(f"active:{date}", user_id2)
...
pipeline.execute()

2.5java代码实现

// 1. 配置类
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new StringRedisSerializer());
        return template;
    }
}

// 2. 活跃度统计服务
@Service
@Slf4j
public class UserActivityService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String KEY_PREFIX = "hyperloglog:user:active:";
    
    /**
     * 记录用户活跃
     * @param bizType 业务类型(如game, shop等)
     * @param userId 用户ID
     * @param date 日期
     */
    public void recordUserActivity(String bizType, Long userId, LocalDate date) {
        try {
            String key = buildKey(bizType, date);
            redisTemplate.opsForHyperLogLog().add(key, String.valueOf(userId));
        } catch (Exception e) {
            log.error("Failed to record user activity: bizType={}, userId={}, date={}", 
                      bizType, userId, date, e);
            throw new RuntimeException("Failed to record user activity", e);
        }
    }
    
    /**
     * 批量记录用户活跃
     */
    public void recordUserActivities(String bizType, List<Long> userIds, LocalDate date) {
        try {
            String key = buildKey(bizType, date);
            String[] users = userIds.stream()
                                  .map(String::valueOf)
                                  .toArray(String[]::new);
            redisTemplate.opsForHyperLogLog().add(key, users);
        } catch (Exception e) {
            log.error("Failed to batch record user activities: bizType={}, userCount={}, date={}", 
                      bizType, userIds.size(), date, e);
            throw new RuntimeException("Failed to batch record user activities", e);
        }
    }
    
    /**
     * 获取日活跃用户数(DAU)
     */
    public long getDailyActiveUsers(String bizType, LocalDate date) {
        String key = buildKey(bizType, date);
        return redisTemplate.opsForHyperLogLog().size(key);
    }
    
    /**
     * 获取周活跃用户数(WAU)
     */
    public long getWeeklyActiveUsers(String bizType, LocalDate endDate) {
        // 获取前7天的key
        List<String> keys = new ArrayList<>();
        for (int i = 0; i < 7; i++) {
            LocalDate date = endDate.minusDays(i);
            keys.add(buildKey(bizType, date));
        }
        
        // 合并统计
        String mergeKey = buildKey(bizType, endDate) + ":week";
        redisTemplate.opsForHyperLogLog().union(mergeKey, 
            keys.toArray(new String[0]));
        
        return redisTemplate.opsForHyperLogLog().size(mergeKey);
    }
    
    /**
     * 获取月活跃用户数(MAU)
     */
    public long getMonthlyActiveUsers(String bizType, LocalDate endDate) {
        // 获取前30天的key
        List<String> keys = new ArrayList<>();
        for (int i = 0; i < 30; i++) {
            LocalDate date = endDate.minusDays(i);
            keys.add(buildKey(bizType, date));
        }
        
        // 合并统计
        String mergeKey = buildKey(bizType, endDate) + ":month";
        redisTemplate.opsForHyperLogLog().union(mergeKey, 
            keys.toArray(new String[0]));
        
        return redisTemplate.opsForHyperLogLog().size(mergeKey);
    }
    
    /**
     * 获取指定时间范围的活跃用户数
     */
    public long getActiveUsersByDateRange(String bizType, LocalDate startDate, LocalDate endDate) {
        // 获取日期范围内的所有key
        List<String> keys = new ArrayList<>();
        LocalDate currentDate = startDate;
        while (!currentDate.isAfter(endDate)) {
            keys.add(buildKey(bizType, currentDate));
            currentDate = currentDate.plusDays(1);
        }
        
        // 合并统计
        String mergeKey = buildKey(bizType, endDate) + ":range";
        redisTemplate.opsForHyperLogLog().union(mergeKey, 
            keys.toArray(new String[0]));
        
        return redisTemplate.opsForHyperLogLog().size(mergeKey);
    }
    
    /**
     * 获取多个业务维度的活跃用户数
     */
    public Map<String, Long> getMultiDimensionActiveUsers(List<String> bizTypes, LocalDate date) {
        Map<String, Long> result = new HashMap<>();
        for (String bizType : bizTypes) {
            result.put(bizType, getDailyActiveUsers(bizType, date));
        }
        return result;
    }
    
    private String buildKey(String bizType, LocalDate date) {
        return KEY_PREFIX + bizType + ":" + date.format(DateTimeFormatter.ISO_DATE);
    }
}

// 3. Controller层示例
@RestController
@RequestMapping("/api/activity")
@Slf4j
public class UserActivityController {
    @Autowired
    private UserActivityService activityService;
    
    @PostMapping("/record")
    public ResponseEntity<String> recordActivity(
            @RequestParam String bizType,
            @RequestParam Long userId) {
        activityService.recordUserActivity(bizType, userId, LocalDate.now());
        return ResponseEntity.ok("Success");
    }
    
    @GetMapping("/stats/daily")
    public ResponseEntity<Map<String, Object>> getDailyStats(
            @RequestParam String bizType,
            @RequestParam(required = false) 
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
        
        date = date == null ? LocalDate.now() : date;
        
        Map<String, Object> stats = new HashMap<>();
        stats.put("bizType", bizType);
        stats.put("date", date);
        stats.put("activeUsers", activityService.getDailyActiveUsers(bizType, date));
        
        return ResponseEntity.ok(stats);
    }
    
    @GetMapping("/stats/weekly")
    public ResponseEntity<Map<String, Object>> getWeeklyStats(
            @RequestParam String bizType,
            @RequestParam(required = false) 
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
        
        endDate = endDate == null ? LocalDate.now() : endDate;
        
        Map<String, Object> stats = new HashMap<>();
        stats.put("bizType", bizType);
        stats.put("endDate", endDate);
        stats.put("startDate", endDate.minusDays(6));
        stats.put("activeUsers", activityService.getWeeklyActiveUsers(bizType, endDate));
        
        return ResponseEntity.ok(stats);
    }
}

// 4. 测试类
@SpringBootTest
class UserActivityServiceTest {
    @Autowired
    private UserActivityService activityService;
    
    @Test
    void testDailyActiveUsers() {
        String bizType = "test";
        LocalDate date = LocalDate.now();
        
        // 记录100个用户活跃
        List<Long> userIds = IntStream.range(1, 101)
                                    .mapToObj(Long::valueOf)
                                    .collect(Collectors.toList());
        
        activityService.recordUserActivities(bizType, userIds, date);
        
        long count = activityService.getDailyActiveUsers(bizType, date);
        assertEquals(100, count);
    }
    
    @Test
    void testWeeklyActiveUsers() {
        String bizType = "test";
        LocalDate endDate = LocalDate.now();
        
        // 记录7天的用户活跃数据
        for (int i = 0; i < 7; i++) {
            LocalDate date = endDate.minusDays(i);
            List<Long> userIds = IntStream.range(1, 101)
                                        .mapToObj(Long::valueOf)
                                        .collect(Collectors.toList());
            activityService.recordUserActivities(bizType, userIds, date);
        }
        
        long count = activityService.getWeeklyActiveUsers(bizType, endDate);
        assertTrue(count > 0);
    }
}


网站公告

今日签到

点亮在社区的每一天
去签到