IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章,助你少走弯路。
前四篇,我们把 Redis 五大基础数据结构(String、Hash、List、Set、Sorted Set)全部吃透了。你已经能用它们解决缓存、计数、对象存储、消息队列、排行榜等绝大多数业务问题。
但 Redis 的"兵器库"里还有三件常被忽视的"特种兵器":位图(Bitmap) 、HyperLogLog 和 GEO。它们不是独立的第六、七、八种数据类型------本质上,位图和 HyperLogLog 是 String 的特殊用法,GEO 是 Sorted Set 的一层封装。但它们解决的问题太特殊、太好用了,值得单独一篇文章来讲述。
-
想用 极低内存 记录海量用户的签到状态?→ 位图
-
想统计 亿级 UV 而内存只占 12KB?→ HyperLogLog
-
想实现 "附近的人" 功能且支持距离排序?→ GEO
读完这篇,你的 Redis 工具箱将再无死角。
1. 位图(Bitmap)------ 海量二值状态的极致压缩
1.1 位图是什么?
位图不是新类型,它就是 String 。只不过我们不再把 String 看成字符串,而是看成一个 比特数组,每个比特位(0 或 1)代表一个布尔状态。
bash
String 值: "abc" (3 字节 = 24 比特)
比特位: 0 1 1 0 0 0 0 1 0 1 1 0 0 0 1 0 0 1 1 0 0 0 1 1
偏移量: 0 1 2 3 4 5 6 7 8 9 10 ...
Redis 提供了直接操作比特位的命令,我们可以把任意 offset 位置的值设为 0 或 1。这样,用 1 个比特就能记录一个用户的签到、一个设备的在线状态,内存利用率是 String 直接存用户 ID 的上万倍。
💡 按理论值:1MB 的位图可以记录 800 多万个用户的状态。
1.2 核心命令速览
bash
# 设置偏移量 offset 的比特值
SETBIT key offset value # value 只能是 0 或 1
# 获取偏移量 offset 的比特值
GETBIT key offset
# 统计值为 1 的比特位个数
BITCOUNT key [start end] # start/end 是字节位置,不是比特!
# 位图间的位运算(AND, OR, XOR, NOT),结果存入新 key
BITOP operation destkey key1 [key2 ...]
# 查找第一个值为 bit 的位置
BITPOS key bit [start] [end]
动手体验:
bash
# 模拟用户签到,用户 ID 作为 offset
127.0.0.1:6379> SETBIT sign:2026-06-10 1001 1
(integer) 0
127.0.0.1:6379> SETBIT sign:2026-06-10 1005 1
(integer) 0
127.0.0.1:6379> SETBIT sign:2026-06-10 2000 1
(integer) 0
# 查询用户是否签到
127.0.0.1:6379> GETBIT sign:2026-06-10 1001
(integer) 1
127.0.0.1:6379> GETBIT sign:2026-06-10 1002
(integer) 0
# 统计签到总人数
127.0.0.1:6379> BITCOUNT sign:2026-06-10
(integer) 3
# 查找第一个签到用户的 offset
127.0.0.1:6379> BITPOS sign:2026-06-10 1
(integer) 1001
1.3 Python 实战:用户签到系统
场景:记录用户每日签到、查询连续签到天数(简化版),以及按月统计活跃天数。
bash
import redis
from datetime import date, timedelta
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
class DailySignIn:
"""基于位图的用户签到系统"""
def __init__(self, prefix='sign'):
self.prefix = prefix
def _key(self, dt):
"""生成键名,如 sign:2026-06-10"""
return f'{self.prefix}:{dt.strftime("%Y-%m-%d")}'
def sign(self, user_id, dt=None):
"""用户签到"""
if dt is None:
dt = date.today()
return r.setbit(self._key(dt), user_id, 1)
def is_signed(self, user_id, dt=None):
"""检查是否签到"""
if dt is None:
dt = date.today()
return r.getbit(self._key(dt), user_id) == 1
def count_signed(self, dt=None):
"""统计当天签到人数"""
if dt is None:
dt = date.today()
return r.bitcount(self._key(dt))
def continuous_days(self, user_id, end_date=None, max_days=30):
"""计算从 end_date 往前连续签到天数(简化版)"""
if end_date is None:
end_date = date.today()
# 用 BITFIELD 批量获取多个比特位(也可以逐天 GETBIT)
days = 0
for i in range(max_days):
check_date = end_date - timedelta(days=i)
if r.getbit(self._key(check_date), user_id) == 1:
days += 1
else:
break
return days
def monthly_active_mask(self, user_id, year, month):
"""生成某月每天签到情况的二进制字符串 (1=已签, 0=未签)"""
import calendar
_, days_in_month = calendar.monthrange(year, month)
mask = ''
for day in range(1, days_in_month + 1):
dt = date(year, month, day)
mask += '1' if self.is_signed(user_id, dt) else '0'
return mask
# ----------------- 测试 -----------------
sign_in = DailySignIn()
# 模拟用户签到
print("用户 1001 签到:", sign_in.sign(1001)) # 0 (首次设置返回旧值 0)
print("用户 1002 签到:", sign_in.sign(1002))
print("用户 1003 签到:", sign_in.sign(1003))
# 重复签到(不会产生副作用)
print("用户 1001 再次签到:", sign_in.sign(1001)) # 1 (已为 1,返回旧值 1)
# 检查签到状态
print("用户 1001 是否签到:", sign_in.is_signed(1001)) # True
print("用户 9999 是否签到:", sign_in.is_signed(9999)) # False
# 统计签到人数
print(f"今日签到人数: {sign_in.count_signed()}")
# 模拟连续签到天数(需要在之前几天也有数据)
# 手动设置过去 3 天的签到
for i in range(1, 4):
past_date = date.today() - timedelta(days=i)
r.setbit(f'sign:{past_date.strftime("%Y-%m-%d")}', 1001, 1)
print(f"用户 1001 连续签到天数: {sign_in.continuous_days(1001)}")
输出示例:
bash
用户 1001 签到: 0
用户 1002 签到: 0
用户 1003 签到: 0
用户 1001 再次签到: 1
用户 1001 是否签到: True
用户 9999 是否签到: False
今日签到人数: 3
用户 1001 连续签到天数: 4
🧪 动手试试 :运行代码后,尝试模拟 1000 个用户签到,用
BITCOUNT验证签到人数。然后对比:如果用 String 存储每个用户签到状态(如SET sign:user:1001 1),会占用多少内存?位图方式只占用一个 offset,高下立判。
1.4 位图运算:BITOP
BITOP 可以对多个位图执行 AND、OR、XOR、NOT 操作,结果存入一个新 key。这在统计分析中非常强大。
bash
# 两个日期的位图做 AND,得到两天都签到的用户
127.0.0.1:6379> SETBIT day1 1 1
127.0.0.1:6379> SETBIT day1 2 1
127.0.0.1:6379> SETBIT day2 1 1
127.0.0.1:6379> SETBIT day2 3 1
127.0.0.1:6379> BITOP AND both_days day1 day2
(integer) 1
127.0.0.1:6379> BITCOUNT both_days
(integer) 1 # 只有用户 1 两天都签到
Python 示例------统计连续 7 天都签到的用户:
bash
def continuous_7days_users(r, prefix, start_date):
"""找出从 start_date 开始连续 7 天都签到的用户数(简化:用 AND 运算)"""
keys = []
for i in range(7):
dt = start_date + timedelta(days=i)
keys.append(f'{prefix}:{dt.strftime("%Y-%m-%d")}')
# BITOP AND 将所有天的位图做与运算
r.bitop('AND', 'tmp:continuous_7days', *keys)
count = r.bitcount('tmp:continuous_7days')
r.delete('tmp:continuous_7days') # 清理临时 key
return count
2. HyperLogLog ------ 海量数据的基数统计
2.1 为什么要 HyperLogLog?
"统计网页 UV" 是一个经典需求。最直观的做法:用 Set 存储每个用户的 ID,SCARD 获取总数。但如果日活百万甚至千万,Set 内存开销巨大(一个用户 ID 假设 20 字节,1000 万用户就是 200MB)。
HyperLogLog(简称 HLL)专为此而生:它用极小的固定内存(最多 12KB),以 0.81% 的标准误差估算集合的基数(不重复元素个数)。
⚠️ 注意:HLL 不能获取具体元素,只能估算"不重复的个数"。
2.2 核心命令
bash
# 向 HyperLogLog 添加元素
PFADD key element [element ...]
# 估算基数(不重复元素个数)
PFCOUNT key [key ...]
# 合并多个 HyperLogLog 到新 key
PFMERGE destkey sourcekey [sourcekey ...]
命令都很简单,前缀 PF 是为了纪念发明人 Philippe Flajolet。
bash
127.0.0.1:6379> PFADD uv:page1 user1 user2 user3 user4 user5
(integer) 1
127.0.0.1:6379> PFADD uv:page1 user3 user6 # user3 重复,忽略
(integer) 1
127.0.0.1:6379> PFCOUNT uv:page1
(integer) 6
2.3 Python 实战:网站 UV 统计
bash
import redis
import random
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
class UVTracker:
"""基于 HyperLogLog 的 UV 统计器"""
def __init__(self):
pass
def record_visit(self, page, user_id):
"""记录一次页面访问"""
return r.pfadd(f'uv:{page}', user_id)
def get_uv(self, page):
"""获取页面 UV(估算值)"""
return r.pfcount(f'uv:{page}')
def merge_uv(self, dest_page, *source_pages):
"""合并多个页面的 UV,得到总的独立访客数"""
keys = [f'uv:{p}' for p in source_pages]
r.pfmerge(f'uv:{dest_page}', *keys)
return r.pfcount(f'uv:{dest_page}')
# ----------------- 测试 -----------------
tracker = UVTracker()
# 模拟 10000 次页面访问,其中独立用户约 5000 个
print("模拟用户访问中...")
for i in range(10000):
user_id = f'user_{random.randint(1, 5000)}' # 5000 个独立用户
tracker.record_visit('home', user_id)
uv = tracker.get_uv('home')
print(f"页面 home 的 UV 估算值: {uv}")
print(f"实际独立用户数: 5000")
print(f"误差: {abs(uv - 5000) / 5000 * 100:.2f}%")
# 合并多个页面
for i in range(5000):
user_id = f'user_{random.randint(1, 3000)}'
tracker.record_visit('product', user_id)
total = tracker.merge_uv('total', 'home', 'product')
print(f"合并 home + product 去重 UV 估算: {total}")
print(f"实际去重 UV (理论上限): 5000 + 3000 = 8000,但会有重叠")
# 验证内存占用
print(f"\n内存占用(约): {r.memory_usage('uv:home')} bytes")
输出示例:
bash
模拟用户访问中...
页面 home 的 UV 估算值: 4987
实际独立用户数: 5000
误差: 0.26%
合并 home + product 去重 UV 估算: 6459
内存占用(约): 12304 bytes
🧪 动手试试 :尝试修改代码,把
user_id的范围改成 1 到 10 万(10 万独立用户),观察 HLL 估算误差是否仍在 1% 以内。
2.4 适用场景与局限
✅ 适用:
-
网页 / 应用 UV 统计
-
搜索热词去重计数
-
广告曝光去重人数
❌ 不适用:
-
需要获取具体元素列表
-
需要精确计数(如金钱相关)
-
小数据量(几千以内),直接用 Set 更精确
3. GEO ------ 地理位置与"附近的人"
3.1 GEO 是什么?
GEO 是 Redis 3.2+ 引入的地理位置模块,底层基于 Sorted Set。它用 Geohash 算法将经纬度编码为一个数值作为 score,存储在 ZSet 中,从而实现地理位置的距离计算、范围查询等功能。
Redis GEO 命令内部操作的都是 ZSet,所以你也可以用 ZSet 的命令操作 GEO 键。
3.2 核心命令速览
bash
# 添加地理位置
GEOADD key longitude latitude member [longitude latitude member ...]
# 获取位置坐标
GEOPOS key member [member ...]
# 计算两个位置之间的距离
GEODIST key member1 member2 [m|km|ft|mi]
# 获取指定位置的 Geohash 字符串
GEOHASH key member [member ...]
# 查询指定半径内的成员
GEOSEARCH key FROMMEMBER member BYRADIUS radius unit [WITHCOORD] [WITHDIST] [COUNT count]
GEOSEARCH key FROMLONLAT lon lat BYRADIUS radius unit
# GEOSEARCH 是 Redis 6.2+ 统一命令,老版本用 GEORADIUS / GEORADIUSBYMEMBER
快速体验:
bash
# 添加几个地点的经纬度
127.0.0.1:6379> GEOADD cities 116.397 39.908 "北京" 121.473 31.230 "上海" 113.264 23.129 "广州" 114.057 22.543 "深圳"
(integer) 4
# 获取坐标
127.0.0.1:6379> GEOPOS cities 北京 上海
1) 1) "116.39700299501419067"
2) "39.90799927282371714"
2) 1) "121.47300094366073608"
2) "31.22999903983650159"
# 计算距离
127.0.0.1:6379> GEODIST cities 北京 上海 km
"1066.4336"
# 查询深圳附近 200km 内的城市
# 使用 GEOSEARCH (Redis 6.2+)
127.0.0.1:6379> GEOSEARCH cities FROMMEMBER 深圳 BYRADIUS 200 km WITHDIST
1) 1) "深圳"
2) "0.0000"
2) 1) "广州"
2) "104.7797"
# 如果 Redis < 6.2,用 GEORADIUSBYMEMBER
127.0.0.1:6379> GEORADIUSBYMEMBER cities 深圳 200 km WITHDIST
1) 1) "深圳"
2) "0.0000"
2) 1) "广州"
2) "104.7797"
3.3 Python 实战:"附近的人"功能
场景:用户打开 App,按距离排序查询附近的商家。
bash
import redis
import random
import math
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
class NearbyService:
"""基于 GEO 的"附近的人/商家"服务"""
GEO_KEY = 'locations:merchants'
def add_merchant(self, name, longitude, latitude):
"""添加商家位置"""
return r.geoadd(self.GEO_KEY, (longitude, latitude, name))
def get_position(self, name):
"""获取商家坐标"""
pos = r.geopos(self.GEO_KEY, name)
if pos and pos[0]:
return {'longitude': pos[0][0], 'latitude': pos[0][1]}
return None
def get_distance(self, name1, name2, unit='km'):
"""获取两个商家之间的距离"""
return r.geodist(self.GEO_KEY, name1, name2, unit=unit)
def search_nearby(self, longitude, latitude, radius, unit='km', count=None):
"""查询指定坐标附近 radius 范围内的商家,按距离排序"""
kwargs = {'radius': radius, 'unit': unit, 'withdist': True, 'withcoord': True, 'sort': 'ASC'}
if count:
kwargs['count'] = count
# GEOSEARCH 在 redis-py 4.0+ 可用
results = r.geosearch(self.GEO_KEY, longitude=longitude, latitude=latitude, **kwargs)
merchants = []
for item in results:
merchants.append({
'name': item[0],
'distance': item[1],
'longitude': item[2][0],
'latitude': item[2][1]
})
return merchants
def search_nearby_member(self, member, radius, unit='km', count=None):
"""查询指定商家附近的其他商家"""
kwargs = {'radius': radius, 'unit': unit, 'withdist': True, 'withcoord': True, 'sort': 'ASC'}
if count:
kwargs['count'] = count
results = r.geosearch(self.GEO_KEY, member=member, **kwargs)
merchants = []
for item in results:
merchants.append({
'name': item[0],
'distance': item[1],
'longitude': item[2][0],
'latitude': item[2][1]
})
return merchants
# ----------------- 测试 -----------------
service = NearbyService()
# 初始化一批北京和上海周边的商家
merchants = [
# 北京周边
('星巴克(国贸店)', 116.461, 39.909),
('海底捞(三里屯)', 116.455, 39.932),
('麦当劳(望京店)', 116.491, 39.998),
('全聚德(前门店)', 116.397, 39.899),
# 上海周边
('星巴克(南京路店)', 121.475, 31.233),
('海底捞(陆家嘴店)', 121.508, 31.237),
('喜茶(人民广场)', 121.473, 31.230),
('外婆家(静安寺)', 121.446, 31.224),
]
for name, lon, lat in merchants:
service.add_merchant(name, lon, lat)
print("=== 查询"我"附近的商家 ===")
# 假设我在北京国贸附近
my_lon, my_lat = 116.460, 39.905
nearby = service.search_nearby(my_lon, my_lat, radius=5, unit='km')
for m in nearby:
print(f" {m['name']} --- {m['distance']:.2f} km")
print("\n=== 查询"星巴克(国贸店)"5km 内的其他商家 ===")
nearby_starbucks = service.search_nearby_member('星巴克(国贸店)', radius=5, unit='km')
for m in nearby_starbucks:
print(f" {m['name']} --- {m['distance']:.2f} km")
print("\n=== 北京上海两地距离 ===")
dist = service.get_distance('全聚德(前门店)', '喜茶(人民广场)', unit='km')
print(f"全聚德(前门店) ↔ 喜茶(人民广场): {dist} km")
输出示例:
bash
=== 查询"我"附近的商家 ===
星巴克(国贸店) --- 0.46 km
海底捞(三里屯) --- 3.03 km
全聚德(前门店) --- 5.00 km
=== 查询"星巴克(国贸店)"5km 内的其他商家 ===
星巴克(国贸店) --- 0.00 km
海底捞(三里屯) --- 3.49 km
全聚德(前门店) --- 5.00 km
=== 北京上海两地距离 ===
全聚德(前门店) ↔ 喜茶(人民广场): 1066.47 km
3.4 GEO 底层与注意事项
GEO 底层是一个 ZSet,score 是坐标的 Geohash 编码(52 位整数)。这意味着:
-
可以用
ZREM删除位置、ZCARD获取数量。 -
不要混用 ZADD 直接添加,会破坏 Geohash 编码,导致 GEO 命令异常。
Redis 7 还引入了 GEOSEARCHSTORE,可以将查询结果存储为新的 ZSet,方便做二次处理。
4. 总结对比
三大高级结构,各自解决了特定领域的问题,共同特点是:用最小的内存,做最大的事。它们是 Redis 在"基础数据结构"之外的精华扩展,也是你从"会用 Redis"迈向"精通 Redis"的必经之路。
5. 动手试试
在你的 Redis 环境中完成以下挑战:
-
连续签到统计 :模拟 10 个用户过去 7 天的签到数据(用位图),用
BITOP AND找出连续 7 天全勤的用户。 -
UV 准确度验证:用同一个数据集(10000 条数据,5000 个唯一用户),同时写入 Set 和 HyperLogLog,对比统计结果,计算误差。
-
外卖配送范围:录入 20 个商家坐标,模拟用户在某个位置,查询 3km 范围内的商家,按距离排序。
预期效果:1. 全勤用户精准筛选;2. HLL 误差在 1% 以内;3. 附近商家列表距离正确。
从下一篇开始,我们将切换视角,聚焦 Python 如何优雅地操作 Redis ,深入 redis-py 的连接池、Pipeline 等工程化技巧,让代码更具生产级风范。
想了解更多还可以去各个平台搜索「IT策士」,一起升级 IT 思维 !