用极低的内存消耗,实现签到统计、UV统计、附近的人功能,这才是Redis的真正实力!
在Redis生态中,String、Hash、List、Set、Sorted Set这五种基础类型占据了90%以上的使用场景。但当遇到以下需求时,它们可能会显得笨重或低效:
- 记录用户每天签到状态,需要存储上亿条记录,且查询某天签到人数
- 统计网站UV(独立访客),要求极低的内存占用,允许一定误差
- 实现"附近的人"功能,需要地理位置索引和范围查询
此时,Redis提供的三种特殊数据类型——Bitmap、HyperLogLog、GEO便闪亮登场。它们专为这类场景设计,在保证性能的同时,将内存消耗降到最低。
🔥 一、Bitmap:位图——签到统计的终极方案
📌 原理简介
Bitmap本质上是对String类型的扩展,它把字符串的每个bit位当作一个独立的单位进行读写。一个字节(byte)包含8个bit,因此一个10万位的Bitmap仅占用约12.5KB内存。
核心命令:
SETBIT key offset value # 设置指定偏移位的值(0/1)
GETBIT key offset # 获取指定偏移位的值
BITCOUNT key [start end] # 统计值为1的bit数量
BITOP operation destkey key [key...] # 对多个Bitmap进行与、或、非、异或操作
🛠️ 业务场景:用户签到统计
需求:记录用户每日签到,统计月度签到次数,展示签到日历,以及统计某天总签到人数。
设计思路:
- 每个用户一个Bitmap,key为
sign:userId:yearMonth(如sign:1001:202403) - 偏移量(offset)使用当月的第几天(1~31),bit值为1表示已签到
Spring Boot实现:
@Service
public class SignService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 用户签到
* @param userId 用户ID
* @param date 签到日期(格式 yyyy-MM-dd)
*/
public void sign(Long userId, LocalDate date) {
String key = buildSignKey(userId, date);
int offset = date.getDayOfMonth() - 1;
redisTemplate.opsForValue().setBit(key, offset, true);
}
/**
* 获取用户某月的签到次数
*/
public long getSignCount(Long userId, YearMonth yearMonth) {
String key = buildSignKey(userId, yearMonth);
return redisTemplate.opsForValue().bitCount(key);
}
/**
* 获取用户某月的签到记录(用于前端日历展示)
*/
public String getSignRecord(Long userId, YearMonth yearMonth) {
String key = buildSignKey(userId, yearMonth);
byte[] bytes = redisTemplate.opsForValue().get(key).getBytes();
if (bytes == null) return " ";
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%8s", Integer.toBinaryString(b & 0xFF)).replace(' ', '0'));
}
int days = yearMonth.lengthOfMonth();
return sb.substring(0, days);
}
private String buildSignKey(Long userId, YearMonth yearMonth) {
return "sign:" + userId + ":" + yearMonth.toString().replace("-", "");
}
}
⚠️ 注意事项
- 签到记录存储在Bitmap中,无需额外索引,查询效率极高
- 适合千万级用户签到数据,内存占用仅几十MB
- 统计某天总签到人数时,建议使用另一个Bitmap存储每日签到汇总,避免对所有用户Bitmap做BITOP操作
🔥 二、HyperLogLog:基数统计——UV统计的轻量级王者
📌 原理简介
HyperLogLog是一种概率性数据结构,用于统计集合中不重复元素的数量(即基数),误差约为0.81%。每个HyperLogLog键只占用12KB内存,无论存储多少元素,内存固定。
核心命令:
PFADD key element [element...] # 添加元素
PFCOUNT key [key...] # 返回基数的估算值
PFMERGE destkey sourcekey [sourcekey...] # 合并多个HyperLogLog
🛠️ 业务场景:网站UV统计
需求:统计某个页面每天的独立访客数(UV),允许一定误差。
设计思路:
- 每天一个HyperLogLog键,如
uv:20240330 - 用户访问时,将用户ID(或IP)添加到对应的HyperLogLog中
- 使用
PFCOUNT获取当天UV
Spring Boot实现:
@Service
public class UVService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 记录用户访问
* @param date 访问日期
* @param userId 用户ID
*/
public void visit(LocalDate date, Long userId) {
String key = "uv:" + date.toString().replace("-", "");
redisTemplate.opsForHyperLogLog().add(key, userId.toString());
}
/**
* 获取某天的UV
*/
public long getUV(LocalDate date) {
String key = "uv:" + date.toString().replace("-", "");
return redisTemplate.opsForHyperLogLog().size(key);
}
/**
* 获取多天累计UV(去重)
*/
public long getMultiDayUV(LocalDate start, LocalDate end) {
List<String> keys = new ArrayList<>();
for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
keys.add("uv:" + date.toString().replace("-", ""));
}
String tempKey = "uv:temp:" + System.currentTimeMillis();
redisTemplate.opsForHyperLogLog().union(tempKey, keys.toArray(new String[0]));
long count = redisTemplate.opsForHyperLogLog().size(tempKey);
redisTemplate.delete(tempKey);
return count;
}
}
⚠️ 注意事项
- HyperLogLog适合海量数据、允许误差的场景,不适合精确计数
- 合并操作(
PFMERGE)会消耗CPU,建议在离线任务中执行 - 实际使用中,可以设置
PFADD的元素为userId:ip,避免同一用户多次访问导致重复计数
🔥 三、GEO:地理位置——附近的人、商户查询
📌 原理简介
GEO是Redis 3.2引入的数据类型,基于Sorted Set实现,支持存储经纬度坐标,并提供距离计算、半径查询等功能。
核心命令:
GEOADD key longitude latitude member [longitude latitude member ...] # 添加位置
GEOPOS key member [member ...] # 获取指定成员的坐标
GEODIST key member1 member2 [unit] # 计算两个成员之间的距离
GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [COUNT count] # 查询指定半径内的成员
GEORADIUSBYMEMBER key member radius unit ... # 以某个成员为中心进行半径查询
🛠️ 业务场景:附近的人
需求:基于用户当前经纬度,查询附近一定范围内的其他用户,并按距离排序。
设计思路:
- 存储用户位置:
GEOADD location:city 经度 纬度 用户ID - 查询附近用户:
GEORADIUS location:city 经度 纬度 半径 km/km WITHDIST COUNT 20
Spring Boot实现:
@Service
public class GeoService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String GEO_KEY = "location:beijing";
/**
* 更新用户位置
*/
public void updateLocation(Long userId, double lng, double lat) {
redisTemplate.opsForGeo().add(
GEO_KEY,
new Point(lng, lat),
userId.toString()
);
}
/**
* 获取附近的人(按距离排序)
* @param userId 当前用户ID
* @param radius 半径(米)
* @param limit 最多返回数量
* @return 列表,元素为 (userId, distance)
*/
public List<NearbyUser> getNearbyUsers(Long userId, double radius, int limit) {
List<Point> points = redisTemplate.opsForGeo().position(GEO_KEY, userId.toString());
if (points == null || points.isEmpty()) {
return Collections.emptyList();
}
Point center = points.get(0);
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs
.newGeoRadiusArgs()
.includeDistance()
.sortAscending()
.limit(limit);
GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo()
.radius(GEO_KEY, new Circle(center, new Distance(radius, Metrics.METERS)), args);
if (results == null) return Collections.emptyList();
List<NearbyUser> list = new ArrayList<>();
for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
RedisGeoCommands.GeoLocation<String> location = result.getContent();
if (location.getName().equals(userId.toString())) continue;
double distance = result.getDistance().getValue();
list.add(new NearbyUser(Long.parseLong(location.getName()), distance));
}
return list;
}
// 内部类
static class NearbyUser {
private Long userId;
private double distance; // 单位:米
public NearbyUser(Long userId, double distance) {
this.userId = userId;
this.distance = distance;
}
// getter, setter
}
}
⚠️ 注意事项
- GEO的底层是Sorted Set,无法直接修改某个成员的坐标,需要先删除再添加
- 半径查询的精度受geohash编码影响,但Redis内部已做了优化,误差很小
- 对于城市级数据,建议按城市拆分key,避免单key过大导致查询缓慢
📊 四、三种特殊数据类型对比总结
| 数据类型 | 内存消耗 | 精确性 | 典型场景 | 主要命令 |
|---|---|---|---|---|
| Bitmap | 极小(按位存储) | 精确 | 签到统计、布隆过滤器 | SETBIT, GETBIT, BITCOUNT |
| HyperLogLog | 固定12KB | 近似(0.81%) | UV统计、独立访客 | PFADD, PFCOUNT, PFMERGE |
| GEO | 与成员数成正比 | 精确 | 附近的人、地理位置查询 | GEOADD, GEORADIUS, GEODIST |
💡 五、实战小结
Bitmap、HyperLogLog、GEO三种特殊数据类型,虽然不是Redis的日常主力,但在特定场景下,它们往往能以极低的成本和极高的性能解决问题:
✅ 用Bitmap做签到,轻松支撑千万用户每日签到,内存占用仅几十MB
✅ 用HyperLogLog统计UV,告别大数据量的去重开销
✅ 用GEO实现LBS功能,无需引入额外组件
在Spring Boot中,借助Spring Data Redis提供的opsForGeo()、opsForHyperLogLog()、opsForValue().setBit()等API,我们可以非常方便地集成这些能力。
"Redis不是缓存,而是内存中的数据库。"
掌握这些"隐藏技能",让你的Redis使用从"会用"到"精通",代码更优雅,性能更极致。
关注我的微信公众号【卷毛的技术笔记】 我会持续输出后端技术干货,与你一起进阶!
点赞、收藏、转发,让更多开发者受益!