网站或应用访问量达到每日百万级别时,如何统计当天有多少独立用户访问(UV)变成一个技术难题。因为不可能直接在数据库里面为每次访问都插入一条记录,然后午夜在跑一个巨慢的 `SELECT COUNT(DISTINCT user_id)` 查询。这种操作会让数据库不堪重负,响应时间飙升。Redis不仅能帮你轻松应对百万级的UV统计,还能在保证极高速度的同时,将内存消耗控制在一个不可思议的范围内。
实现UV统计,核心是去重计数。用Redis解决这个问题,有三种主流方案,它们在精确度、内存消耗和计算复杂度之间各有权衡,适用于不同场景。
最直观的第一种方案是使用 Redis Set 集合。每个用户标识(如用户ID或设备ID)就是集合里的一个元素,利用集合的天然去重特性,最后用 `SCARD` 命令获取集合大小即可得到UV。操作简单直观,并且结果是绝对精确的。
```python
import redis
r = redis.Redis(host='localhost', port=6379, db=0)
def log_visit(user_id):
"""记录一次用户访问"""
today_key = "uv:" + "2023-10-27" # 按日期组织Key
# SADD 命令会将 user_id 添加到集合中,如果已存在则自动忽略
r.sadd(today_key, user_id)
def get_uv(date):
"""获取指定日期的UV"""
key = "uv:" + date
# SCARD 命令返回集合的基数,即独立用户数
return r.scard(key)
# 模拟记录访问
log_visit("user_123")
log_visit("user_456")
log_visit("user_123") # 重复访问,不会被重复计数
print(get_uv("2023-10-27")) # 输出:2
Set方案的优点是绝对精确、命令简单。但它的致命弱点在于内存消耗。每个用户ID都需要作为一个字符串存储在内存中。假设你有100万独立用户,每个用户ID平均占20字节,那么仅存储这一天的数据就需要近20MB内存。如果需要留存多日数据,开销会线性增长。因此,Set方案更适合用户总量可控(例如十万级别以下)的场景。
为了极致压缩内存,第二种方案 Bitmap(位图) 登场了。它的思路非常巧妙:不再存储用户ID本身,而是把每个用户映射到一个整数偏移量上(比如将用户ID哈希后取模)。一个位图本质上是一个很长的二进制位数组,每个用户只占据其中一个比特位(bit)。如果用户访问过,就将其对应的位设置为1;没访问过,就是0。最后,统计位图中值为1的位数,即可得到UV。
```python
def log_visit_bitmap(user_id):
"""使用Bitmap记录访问"""
today_key = "uv_bitmap:" + "2023-10-27"
# 将 user_id 哈希后取模,映射到一个很大的偏移量上(例如 2^32)
offset = hash(user_id) % (232)
# 使用 SETBIT 命令,将偏移量对应的位设置为1
r.setbit(today_key, offset, 1)
def get_uv_bitmap(date):
"""使用 BITCOUNT 命令统计位图中1的个数"""
key = "uv_bitmap:" + date
return r.bitcount(key)
# 记录访问
log_visit_bitmap("user_123")
log_visit_bitmap("user_456")
log_visit_bitmap("user_123") # 重复访问,同一个位被多次设置为1,不影响结果
print(get_uv_bitmap("2023-10-27")) # 输出:2
Bitmap的内存效率极高。统计1亿用户的访问,也只需要大约 `100,000,000 / 8 / 1024 / 1024 ≈ 12MB` 内存。但它有两个前提:第一,你需要能将用户稳定地映射到一个整数范围内;第二,如果用户ID空间稀疏(即总用户量极大但实际访问用户比例很小),可能会浪费一些内存。Bitmap方案适用于用户ID可数字化、且总量明确的场景(如注册用户ID体系)。
当数据量真正达到海量(数千万甚至上亿),且你对统计结果可以接受极小的误差时,Redis提供的 HyperLogLog 数据结构就是终极武器。HyperLogLog是一种概率算法,它用极小的内存空间(每个HyperLogLog键只需要约12KB内存)来估算一个集合的基数(去重后的元素个数),误差率可以控制在1%以内。
```python
def log_visit_hll(user_id):
"""使用HyperLogLog记录访问"""
today_key = "uv_hll:" + "2023-10-27"
# PFADD 命令将元素添加到HyperLogLog中
r.pfadd(today_key, user_id)
def get_uv_hll(date):
"""使用 PFCOUNT 命令获取估算的UV"""
key = "uv_hll:" + date
return r.pfcount(key)
# HyperLogLog 的使用接口和 Set 一样简单
log_visit_hll("user_123")
log_visit_hll("user_456")
log_visit_hll("user_123")
print(get_uv_hll("2023-10-27")) # 输出一个接近2的估算值,可能是2,也可能是1.98等
HyperLogLog的神奇之处在于,无论你添加1万个还是1亿个元素,它几乎都只占用固定的12KB左右内存。这对于需要长期统计海量UV的场景(如全站历史UV)是革命性的。你可以轻松地将过去365天的每日UV数据常驻内存,而内存消耗仅约 `365 * 12KB ≈ 4.3MB`。代价是它给出的是估算值,不是精确数字,但对于绝大多数宏观数据分析(如流量趋势、渠道对比)来说,1%的误差完全在可接受范围内。
在实际工程中,一个健壮的UV统计系统往往会结合使用这些方案。例如,使用 HyperLogLog 统计全站实时和历史UV,用于监控大盘;使用 Bitmap 统计某个具体活动页面的精准UV,因为活动用户量通常可控且需要精确数字;使用 Set 统计VIP用户等小规模群体的访问情况。此外,还需要注意Key的设计,通常按日期组织(如`uv:20231027`),方便按天统计和设置过期时间,避免数据无限膨胀。
将这些数据从Redis同步到持久化数据库(如MySQL)进行进一步分析,也是常见模式。你可以在每天凌晨,通过一个简单的脚本将前一天的UV值(使用`GET`命令获取已计算好的值,或调用`PFCOUNT`、`SCARD`计算)写入数据库。整个过程对线上Redis服务几乎没有影响。
因此,当面临每天百万甚至更高的访问量时,Redis通过其多样化的数据结构,为你提供了从精准到估算、从省内存到超高并发的全套UV统计解决方案。你不再需要为数据库的`COUNT(DISTINCT)`查询而焦虑,也不再需要为海量数据的存储成本而头疼。
CN
EN