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);
}
}