🔥 Redis 高级数据结构:Bitmap、HyperLogLog、GEO 深度解析
文章目录
🧠 一、高级数据结构全景图
💡 Redis 高级数据结构价值
为什么需要高级数据结构:
- 🚀 极致性能:特殊优化,远超普通实现
- 💾 内存高效:相同数据占用更少内存
- 🔧 功能专精:为解决特定问题而生
- ⚡ 生产验证:经过大规模应用验证
🔢 二、Bitmap:位操作的艺术
💡 内部原理与特性
Bitmap 本质上是 String 类型,但 Redis 提供了专门的位操作命令。每个 bit 位可以存储 0 或 1,极其节省内存。
内存计算示例:
- 1000万用户签到数据 ≈ 10000000 / 8 / 1024 / 1024 ≈ 1.19MB
- 相同数据用 Set 存储 ≈ 至少 100MB
🚀 常用命令详解
# 设置指定偏移量的位值
SETBIT user:sign:202310 100 1 # 用户ID=100在2023/10/01签到
# 获取位值
GETBIT user:sign:202310 100 # 检查用户是否签到
# 统计位数为1的数量
BITCOUNT user:sign:202310 # 统计当天签到总数
# 位运算操作
BITOP AND destkey key1 key2 # 位与运算
BITOP OR destkey key1 key2 # 位或运算
BITOP XOR destkey key1 key2 # 位异或运算
BITOP NOT destkey key # 位非运算
# 查找第一个设置或未设置的位
BITPOS user:sign:202310 1 # 第一个签到的用户
BITPOS user:sign:202310 0 # 第一个未签到的用户
🎯 应用场景案例
1. 用户签到系统:
public class SignService {
// 用户签到
public void sign(Long userId) {
LocalDate today = LocalDate.now();
String key = "user:sign:" + today.format(DateTimeFormatter.ofPattern("yyyyMM"));
long offset = userId % 1000000; // 用户ID偏移量
redis.setbit(key, offset, 1);
// 设置过期时间(1个月)
redis.expire(key, 30 * 24 * 60 * 60);
}
// 检查签到状态
public boolean hasSigned(Long userId) {
LocalDate today = LocalDate.now();
String key = "user:sign:" + today.format(DateTimeFormatter.ofPattern("yyyyMM"));
long offset = userId % 1000000;
return redis.getbit(key, offset) == 1;
}
// 统计当月签到人数
public long getMonthSignCount() {
LocalDate today = LocalDate.now();
String key = "user:sign:" + today.format(DateTimeFormatter.ofPattern("yyyyMM"));
return redis.bitcount(key);
}
// 获取连续签到天数
public int getContinuousSignDays(Long userId) {
List<byte[]> bitFields = new ArrayList<>();
LocalDate endDate = LocalDate.now();
LocalDate startDate = endDate.minusDays(30);
// 获取最近30天的签到数据
while (!startDate.isAfter(endDate)) {
String key = "user:sign:" + startDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
bitFields.add(redis.get(key.getBytes()));
startDate = startDate.plusDays(1);
}
// 计算连续签到天数(实际实现需要更复杂的位运算)
return calculateContinuousDays(bitFields, userId);
}
}
2. 布隆过滤器辅助实现:
public class SimpleBloomFilter {
private static final int SIZE = 2 << 24; // 布隆过滤器大小
private static final int[] SEEDS = new int[]{3, 5, 7, 11, 13, 17, 19, 23}; // 哈希种子
public boolean mightContain(String value) {
for (int seed : SEEDS) {
int hash = hash(value, seed) % SIZE;
if (redis.getbit("bloom:filter", hash) == 0) {
return false;
}
}
return true;
}
public void add(String value) {
for (int seed : SEEDS) {
int hash = hash(value, seed) % SIZE;
redis.setbit("bloom:filter", hash, 1);
}
}
private int hash(String value, int seed) {
int result = 0;
for (int i = 0; i < value.length(); i++) {
result = seed * result + value.charAt(i);
}
return (result & 0x7FFFFFFF);
}
}
📊 三、HyperLogLog:基数估算的魔法
💡 内部原理与特性
HyperLogLog 使用概率算法来估算基数,标准误差为 0.81%,但内存占用极低。
内存优势:
- 统计1亿个不重复元素 ≈ 12KB内存
- 传统Set存储1亿元素 ≈ 至少500MB
🚀 常用命令详解
# 添加元素
PFADD daily:uv:20231001 "user1" "user2" "user3"
# 统计基数
PFCOUNT daily:uv:20231001 # 统计当天UV
# 合并多个HyperLogLog
PFMERGE weekly:uv daily:uv:20231001 daily:uv:20231002
PFCOUNT weekly:uv # 统计周UV
🎯 应用场景案例
1. 网站UV统计:
public class UVStatisticsService {
// 记录每日UV
public void recordUV(String userId) {
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String key = "uv:daily:" + today;
redis.pfadd(key, userId);
// 设置过期时间(2天)
redis.expire(key, 2 * 24 * 60 * 60);
}
// 获取当日UV
public long getTodayUV() {
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String key = "uv:daily:" + today;
return redis.pfcount(key);
}
// 获取多日合并UV
public long getRangeUV(LocalDate start, LocalDate end) {
List<String> keys = new ArrayList<>();
LocalDate current = start;
while (!current.isAfter(end)) {
keys.add("uv:daily:" + current.format(DateTimeFormatter.ofPattern("yyyyMMdd")));
current = current.plusDays(1);
}
String tempKey = "uv:range:temp:" + System.currentTimeMillis();
redis.pfmerge(tempKey, keys.toArray(new String[0]));
long count = redis.pfcount(tempKey);
redis.del(tempKey);
return count;
}
}
2. 实时数据去重统计:
public class RealTimeStatistics {
// 实时统计独立用户数
public void trackUserAction(String action, String userId) {
String key = "action:" + action + ":" +
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHH"));
redis.pfadd(key, userId);
redis.expire(key, 2 * 60 * 60); // 过期时间2小时
}
// 获取小时级统计
public long getHourlyActionCount(String action, LocalDateTime time) {
String key = "action:" + action + ":" +
time.format(DateTimeFormatter.ofPattern("yyyyMMddHH"));
return redis.pfcount(key);
}
}
🌐 四、GEO:地理位置服务
💡 内部原理与特性
GEO 基于 ZSet 实现,使用 Geohash 算法将二维坐标编码为一维字符串。
精度与特性:
- 有效精度:约±0.5米(取决于Geohash精度)
- 支持半径查询、距离计算
- 底层使用ZSet,支持所有ZSet命令
🚀 常用命令详解
# 添加地理位置
GEOADD cities:location 116.405285 39.904989 "北京"
GEOADD cities:location 121.472644 31.231706 "上海"
# 获取地理位置
GEOPOS cities:location "北京"
# 计算距离
GEODIST cities:location "北京" "上海" km
# 半径查询
GEORADIUS cities:location 116.405285 39.904989 100 km WITHDIST
# 获取Geohash值
GEOHASH cities:location "北京"
🎯 应用场景案例
1. 附近的人功能:
public class NearbyService {
// 更新用户位置
public void updateUserLocation(Long userId, double longitude, double latitude) {
String key = "user:location";
redis.geoadd(key, longitude, latitude, "user:" + userId);
}
// 查找附近的人
public List<UserDistance> findNearbyUsers(Long userId, double radius) {
// 先获取当前用户位置
List<GeoCoordinate> position = redis.geopos("user:location", "user:" + userId);
if (position == null || position.isEmpty()) {
return Collections.emptyList();
}
GeoCoordinate coord = position.get(0);
// 查询附近用户
List<GeoRadiusResponse> responses = redis.georadius(
"user:location",
coord.getLongitude(),
coord.getLatitude(),
radius,
GeoUnit.KM,
GeoRadiusParam.geoRadiusParam().withDist()
);
// 转换为用户列表
return responses.stream()
.map(response -> new UserDistance(
response.getMemberByString(),
response.getDistance()
))
.collect(Collectors.toList());
}
// 计算两个用户距离
public Double getDistance(Long user1, Long user2) {
return redis.geodist(
"user:location",
"user:" + user1,
"user:" + user2,
GeoUnit.KM
);
}
}
2. 地理位置搜索:
public class LocationSearchService {
// 添加地点
public void addPlace(Place place) {
redis.geoadd(
"places:location",
place.getLongitude(),
place.getLatitude(),
place.getId()
);
// 同时存储地点详细信息
redis.hset("place:info:" + place.getId(), toMap(place));
}
// 半径搜索
public List<Place> searchNearby(double lng, double lat, double radius) {
List<GeoRadiusResponse> responses = redis.georadius(
"places:location",
lng,
lat,
radius,
GeoUnit.KM,
GeoRadiusParam.geoRadiusParam().withDist()
);
// 批量获取地点详情
List<Place> places = new ArrayList<>();
for (GeoRadiusResponse response : responses) {
String placeId = response.getMemberByString();
Map<String, String> info = redis.hgetAll("place:info:" + placeId);
Place place = toPlace(info);
place.setDistance(response.getDistance());
places.add(place);
}
return places;
}
}
💡 五、总结与选型指南
📊 高级数据结构对比
特性 | Bitmap | HyperLogLog | GEO |
---|---|---|---|
底层实现 | String | 特殊结构 | ZSet |
内存效率 | 极高 | 极高 | 高 |
精度 | 精确 | 近似(0.81%误差) | 精确 |
适用场景 | 二值状态统计 | 基数估算 | 地理位置 |
典型应用 | 签到、布隆过滤器 | UV统计、去重计数 | 附近的人、LBS |
🎯 选型决策指南
🔧 生产环境建议
- Bitmap最佳实践:
- 合理设计偏移量映射规则
- 定期归档历史数据
- 注意大Key问题(单个Bitmap不宜过大)
- HyperLogLog最佳实践:
- 理解并接受误差范围
- 不适合需要精确计数的场景
- 合并多个HLL时误差可能累积
- GEO最佳实践:
- 合理设置Geohash精度
- 结合传统数据库存储详细信息
- 注意半径查询的性能影响
🚀 性能优化技巧
批量操作:使用管道(pipeline)提升批量操作性能
内存优化:定期清理过期数据,控制单个Key大小
架构设计:根据业务特点选择合适的数据结构
监控告警:设置内存使用监控和告警阈值