Redis设计统计用户访问量
需求:实现某个接口每天调用了多少次,每个用户只记录一次。
(例如,统计刷题模块,练题模块,模拟面试模块每天访问量,利于后续针对功能访问量做出其他优化设计。贴子的浏览量)
先分析几种不同的方案:
方案一:使用Hash哈希结构
实现方法:当用户访问网站时,我们可以使用用户的ID作为标识(若用户未登录,则生成一个随机标识)。通过Redis的HSET命令,以URI和日期拼接作为key,用户ID或随机标识作为field,将value设置为1。统计访问量时,使用HLEN命令获取结果。
优点:
-
实现简单,易于理解。
-
查询方便,数据准确性高。
缺点:
-
随着key的增多,内存占用过大,性能可能下降。
-
对于访问量巨大的网站(如拼多多),此方案可能无法承受。
命令:HSET key field value
key:哈希表的名称。
field:哈希表中要设置的字段名称。
value:字段对应的值。
HLEN命令用于获取存储在键(key)处的字段数
HLEN key
如果哈希表存在,返回哈希表中域的数量;如果键不存在,返回0。时间复杂度:O(1)。
方案二:使用Bitset
实现方法:利用Bitset对用户ID进行压缩存储。通过SETBIT命令标记用户访问,使用GETBIT查询用户是否访问,最后通过BITCOUNT统计访问量。
优点:
-
占用内存更小,适用于大规模用户数据。
-
查询方便,可指定查询某个用户。
缺点:
-
用户稀疏时,内存占用可能比方案一更大。
-
对于未登录用户,可能需要额外的映射开销。
语法:setbit key offset value
对key所储存的字符串值,设置或清除指定偏移量上的位(bit)。
位的设置或清除取决于 `value` 参数,可以是 `0` 也可以是 `1` 。
当 `key` 不存在时,自动生成一个新的字符串值。
字符串会进行伸展(grown)以确保它可以将 `value` 保存在指定的偏移量上。
当字符串值进行伸展时,空白位置以 `0` 填充。
注意:
`offset` 参数必须大于或等于 `0` ,小于 2^32 (bit 映射被限制在 512 MB 之内)。
因为 Redis 字符串的大小被限制在 512 兆(megabytes)以内,
所以用户能够使用的最大偏移量为 2^29-1(536870911) , 如果你需要使用比这更大的空间,
请使用多个 `key。`
当生成一个很长的字符串时, Redis 需要分配内存空间, 该操作有时候可能会造成服务器阻塞(block)。 在2010年出产的Macbook Pro上, 设置偏移量为 536870911(512MB 内存分配)将耗费约 300 毫秒, 设置偏移量为 134217728(128MB 内存分配)将耗费约 80 毫秒, 设置偏移量 33554432(32MB 内存分配)将耗费约 30 毫秒, 设置偏移量为 8388608(8MB 内存分配)将耗费约 8 毫秒。
语法:bitcount key [start] [end] 返回值:被设置为 1 的位的数量
计算给定字符串中,被设置为 1 的比特位的数量
一般情况下,给定的整个字符串都会被进行计数,通过指定额外的 start 或 end 参数,
可以让计数只在特定的字节上进行。注意不是bit位,是字节。
例如:假如key1的value是00001100 11001000 11110000
<1> bitcount key1 0 0
这个是获取key1中第0个字节组中bit为1的count,也就是00001100 中查询,返回2
<2> bitcount key1 0 1
这个是获取key1中第0-1个字节组中bit为1的count,也就是00001100 11001000中查询,返回5
<3> bitcount key1 1 2
这个是获取key1中第1-2个字节组中bit为1的count,也就是11001000 11110000中查询,返回7
start 和 end 参数的设置和 GETRANGE key start end 命令类似,都可以使用负数值:
比如 -1表示最后一个bit, -2 表示倒数第二个bit,以此类推。
不存在的 key 被当成是空字符串来处理,因此对一个不存在的 key 进行 BITCOUNT 操作,结果为 0 。
方案三:使用概率算法
实现方法:采用Redis中的HyperLogLog算法,这是一种基数评估算法。使用PFADD命令记录用户访问,通过PFCOUNT命令计算访问量。
优点:
-
占用内存极小,每个key仅需要12KB。
-
非常适合超大规模用户访问量的网站。
缺点:
-
查询指定用户时可能存在误差。
-
总数统计存在一定的误差(约0.81%)。
方案四:Redis的INCR命令
你可以使用Redis的INCR命令,每当有请求发生时,你可以将一个与请求相关的键递增。
统计访问量:创建一个计数器变量来存储访问量,并将初始值设置为0。
使用Redis的INCR命令对该计数器变量进行递增操作。当有用户访问网站时,
每次访问都会执行一次INCR命令,将计数器的值加1。
存储和获取访问量:使用Redis的GET命令获取当前的访问量。
你可以在需要显示或者记录访问量的地方调用GET命令获取实时的访问量值,
然后在网站页面中展示出来。同样地,你可以使用SET命令设置访问量的初始值,
比如在每天凌晨将访问量归零。
高并发场景处理:在高并发场景下,多个用户同时访问网站可能会导致计数器的并发问题。
为了解决这个问题,可以使用Redis的INCRBY命令来进行批量递增。
可以根据实际情况调整批量递增的数量,比如每次递增100或者1000。
数据清理和存储:为了避免存储大量历史访问量数据导致Redis内存占用问题,
可以设置一个定时任务,定期将访问量数据存储到数据库或者文件中。
你可以根据具体需求定制数据存储的方式和周期。
对于很多官方网站的首页,经常会有一些统计首页访问次数的需求。访问次数只有一个字段,如果保存到数据库中,再最后做汇总显然有些麻烦。该业务场景可以使用Redis,定义一个key,比如:OFFICIAL_INDEX_VISIT_COUNT。
在Redis中有incr命令,可以实现给value值加1操作:
incr OFFICIAL_INDEX_VISIT_COUNT
当然如果你想一次加的值大于1,可以用incrby命令:
incrby OFFICIAL_INDEX_VISIT_COUNT 5
案例
场景一:用户签到
考虑到每月初需要重置连续签到次数,按用户每月存一条签到数据(也可以每年存一条数据)。Key的格式为u:sign:uid:yyyyMM
,Value则采用长度为4个字节(32位)的位图(最大月份只有31天)。位图的每一位代表一天的签到,1表示已签,0表示未签。
例如u:sign:1000:201902
表示ID=1000的用户在2019年2月的签到记录。
# 用户2月17号签到
SETBIT u:sign:1000:201902 16 1 # 偏移量是从0开始,所以要把17减1
# 检查2月17号是否签到
GETBIT u:sign:1000:201902 16 # 偏移量是从0开始,所以要把17减1
# 统计2月份的签到次数
BITCOUNT u:sign:1000:201902
# 获取2月份前28天的签到数据
BITFIELD u:sign:1000:201902 get u28 0
# 获取2月份首次签到的日期
BITPOS u:sign:1000:201902 1 # 返回的首次签到的偏移量,加上1即为当月的某一天
代码:
@Slf4j
@Service
public class SignService {
@Autowired
private RedisTemplate redisTemplate;
/**
* 用户签到
*
* @param uid
* @param localDate
* @return
*/
public Boolean doSign(int uid, LocalDate localDate) {
int offset = localDate.getDayOfMonth() - 1;
String signKey = buildSignKey(uid, localDate);
return redisTemplate.opsForValue().setBit(signKey, offset, true);
}
/**
* 检查用户是否签到
*
* @param uid
* @param date
* @return
*/
public Boolean checkSign(int uid, LocalDate date) {
int offset = date.getDayOfMonth() - 1;
String signKey = buildSignKey(uid, date);
return redisTemplate.opsForValue().getBit(signKey, offset);
}
/**
* 获取签到次数
*
* @param uid
* @param date
* @return
*/
public long getSignCount(int uid, LocalDate date) {
String signKey = buildSignKey(uid, date);
return (long) redisTemplate.execute((RedisCallback<Long>) conn -> conn.bitCount(signKey.getBytes()));
}
/**
* 获得当月首次签到日期
*
* @param uid
* @param date
* @return
*/
public LocalDate getFirstSignDate(int uid, LocalDate date) {
String signKey = buildSignKey(uid, date);
long pos = (long) redisTemplate.execute((RedisCallback<Long>) conn -> conn.bitPos(signKey.getBytes(), true));
return pos < 0 ? null : date.withDayOfMonth((int) (pos + 1));
}
/**
* 获取连续签到次数
* 0是高位
*
* @return
*/
public long getContinuousSignCount(int uid, LocalDate date) {
int signCount = 0;
String signKey = buildSignKey(uid, date);
int dayOfMonth = date.getDayOfMonth();
List<Long> list = (List<Long>) redisTemplate.execute((RedisCallback<List<Long>>) conn ->
conn.bitField(signKey.getBytes(),
BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)));
if (list != null && list.size() > 0) {
long v = list.get(0) == null ? 0 : list.get(0);
for (int i = 0; i < date.getDayOfMonth(); i++) {
/**
* 取低位连续不为0的个数即为连续签到次数,需考虑当天尚未签到的情况
*/
if (v >> 1 << 1 == v) {
//低位为0且非当天说明连续签到中断了
if (i > 0) {
break;
}
} else {
signCount += 1;
}
v >>= 1;
}
}
return signCount;
}
public Map<String, Boolean> getSignInfo(int uid, LocalDate date) {
Map<String, Boolean> signMap = new HashMap<>(date.getDayOfMonth());
int lengthOfMonth = date.lengthOfMonth();
String signKey = buildSignKey(uid, date);
List<Long> list = (List<Long>) redisTemplate.execute((RedisCallback<List<Long>>) conn ->
conn.bitField(signKey.getBytes(),
BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(lengthOfMonth)).valueAt(0)));
if (list != null && list.size() > 0) {
long v = list.get(0) == null ? 0 : list.get(0);
for (int i = date.lengthOfMonth(); i > 0; i--) {
LocalDate d = date.withDayOfMonth(i);
signMap.put(formatDate(d, "yyyy-MM-dd"), v >> 1 << 1 != v);
v >>= 1;
}
}
return signMap;
}
/**
* 构建签到key
*
* @param uid
* @param date
* @return
*/
private String buildSignKey(int uid, LocalDate date) {
String monthSuffix = formatDate(date, "yyyyMM");
return String.format("u:sign:%d:%s", uid, monthSuffix);
}
/**
* 日期格式化
*
* @param date
* @param pattern
* @return
*/
public String formatDate(LocalDate date, String pattern) {
return date.format(DateTimeFormatter.ofPattern(pattern));
}
}
场景二:统计活跃用户
使用时间作为cacheKey,然后用户ID为offset,如果当日活跃过就设置为1,那么我该如果计算某几天/月/年的活跃用户呢(暂且约定,统计时间内只有有一天在线就称为活跃),有请下一个redis的命令
命令 BITOP operation destkey key [key ...]
说明:对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。
说明:BITOP 命令支持 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种参数
假设当前站点有5000W用户,那么一天的数据大约为50000000/8/1024/1024=6MB
使用场景三:用户在线状态
对方给我提供了一个查询当前用户是否在线的接口。不了解对方是怎么做的,自己考虑了一下,使用bitmap是一个节约空间效率又高的一种方法,只需要一个key,然后用户ID为offset,如果在线就设置为1,不在线就设置为0,和上面的场景一样,5000W用户只需要6MB的空间。
其他