【Redis】应用场景-统计访问次数(BitMap)

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的空间。 

其他 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

常生果

喜欢我,请支持我

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值