SpringBoot2.x中使用Redis的bitmap结构(工具类)

  1. 一亿个用户,有的用户频繁登录,也有不经常登录的。
  2. 如何记录用户的登录信息?
  3. 如何查询活跃用户?[如一周内 登录三次的]

Redis中文教程
Redis语法大全

我们可以使用Redis的bitmap(位图)来存储数据。

1. 什么叫做Redis的bitmap

即:操作String数据结构的key所存储的字符串指定偏移量上的,返回原位置的值

1.1 优点:

节省空间:通过一个bit位来表示某个元素对应的值或者状态,其中key就是对应元素的值。实际上8个bit可以组成一个Byte,所以是及其节省空间的。

效率高:setbitgetbit的时间复杂度都是O(1),其他位运算效率也高。

1.2 缺点:

本质上只有01的区别,所以用做业务数据记录,就不需要在意value的值。

1.3 使用场景

  1. 可作为简单的布尔过滤器来判断用户是否执行过某些操作;
  2. 可以计算用户日活、月活、留存率的统计;
  3. 可以统计用户在线状态和人数;

2. Redis的bitmap命令

2.1 setbit命令

设置或修改key上的偏移量(offset)的位(value)的值。

  • 语法:setbit key offset value
  • 返回值:指定偏移量(offset)原来存储的值。
    bitmap的setkey指令

2.2 getbit命令

查询key所存储的字符串值,获取偏移量上的

  • 语法:getbit key offset
  • 返回值:返回指定key上的偏移量,若key不存在,那么返回0。
bitmap的getbit指令

2.3 bitcount命令

计算给定key的字符串值中,被设置为1的位bit的数量

  • 语法:bitcount key [start] [end]
  • 返回值:1比特位的数量

注意:setbit是设置或者清除bit位置。这个是统计key出现1的次数。
(小胖友情提示:)需要注意的是:[start][end](单位)实际是byte,这是什么意思呢?进入redis实际上是乘以8。

bitcount指令的使用

2.4 bitop命令

对一个或多个保存二进制的字符串key进行元操作,并将结果保存到destkey上。

  • 语法:operation可以是andornotxor的一种。
  • bitop and destkey key [key...],对一个或多个key逻辑并,结果保存到destkey
  • bitop or destkey key [key...],对一个或多个key逻辑或,结果保存到destkey
  • bitop xor destkey key [key...],对一个或多个key逻辑异或,结果保存到destkey
  • bitop xor destkey key,对一个或多个key逻辑非,结果保存到destkey

除了NOT之外,其他操作多可以接受一个或多个key作为输入。

BITOP的时间复杂度是O(N),当处理大型矩阵或者大量数据统计时,最好将任务指派到附属节点(slave)进行,避免阻塞主节点。

3. SpringBoot中使用

@Component
public class SpringUtils implements ApplicationContextAware {
    private static ApplicationContext applicationContext;

    public static ApplicationContext getApplicationContext() {
        return applicationContext;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        if (SpringUtils.applicationContext == null) {
            SpringUtils.applicationContext = applicationContext;
        }
    }

    public static Object getBean(String name) {
        return getApplicationContext().getBean(name);
    }

    public static <T> T getBean(Class<T> c) {
        return getApplicationContext().getBean(c);
    }

    public static <T> T getBean(String name, Class<T> c) {
        return getApplicationContext().getBean(name, c);
    }


}

工具类:

mport com.google.common.hash.Funnels;
import com.google.common.hash.Hashing;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.nio.charset.Charset;

/**
 * 工具类-提供静态方法
 */
public  class RedisTemplateUtil {

    private static  StringRedisTemplate stringRedisTemplate = SpringUtils.getBean(StringRedisTemplate.class);


    /*********************************************************************************
     *
     * 对bitmap的操作
     *
     ********************************************************************************/

    /**
     * 将指定param的值设置为1,{@param param}会经过hash计算进行存储。
     *
     * @param key   bitmap结构的key
     * @param param 要设置偏移的key,该key会经过hash运算。
     * @param value true:即该位设置为1,否则设置为0
     * @return 返回设置该value之前的值。
     */
    public static Boolean setBit(String key, String param, boolean value) {
        return stringRedisTemplate.opsForValue().setBit(key, hash(param), value);
    }

    /**
     * 将指定param的值设置为0,{@param param}会经过hash计算进行存储。
     *
     * @param key   bitmap结构的key
     * @param param 要移除偏移的key,该key会经过hash运算。
     * @return 若偏移位上的值为1,那么返回true。
     */
    public static Boolean getBit(String key, String param) {
        return stringRedisTemplate.opsForValue().getBit(key, hash(param));
    }


    /**
     * 将指定offset偏移量的值设置为1;
     *
     * @param key    bitmap结构的key
     * @param offset 指定的偏移量。
     * @param value  true:即该位设置为1,否则设置为0
     * @return 返回设置该value之前的值。
     */
    public static Boolean setBit(String key, Long offset, boolean value) {
        return stringRedisTemplate.opsForValue().setBit(key, offset, value);
    }

    /**
     * 将指定offset偏移量的值设置为0;
     *
     * @param key    bitmap结构的key
     * @param offset 指定的偏移量。
     * @return 若偏移位上的值为1,那么返回true。
     */
    public static Boolean getBit(String key, long offset) {
        return stringRedisTemplate.opsForValue().getBit(key, offset);
    }

    /**
     * 统计对应的bitmap上value为1的数量
     *
     * @param key bitmap的key
     * @return value等于1的数量
     */
    public static Long bitCount(String key) {
        return stringRedisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes()));
    }

    /**
     * 统计指定范围中value为1的数量
     *
     * @param key   bitMap中的key
     * @param start 该参数的单位是byte(1byte=8bit),{@code setBit(key,7,true);}进行存储时,单位是bit。那么只需要统计[0,1]便可以统计到上述set的值。
     * @param end   该参数的单位是byte。
     * @return 在指定范围[start*8,end*8]内所有value=1的数量
     */
    public static Long bitCount(String key, int start, int end) {
        return stringRedisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes(), start, end));
    }


    /**
     * 对一个或多个保存二进制的字符串key进行元操作,并将结果保存到saveKey上。
     * <p>
     * bitop and saveKey key [key...],对一个或多个key逻辑并,结果保存到saveKey。
     * bitop or saveKey key [key...],对一个或多个key逻辑或,结果保存到saveKey。
     * bitop xor saveKey key [key...],对一个或多个key逻辑异或,结果保存到saveKey。
     * bitop xor saveKey key,对一个或多个key逻辑非,结果保存到saveKey。
     * <p>
     *
     * @param op      元操作类型;
     * @param saveKey 元操作后将结果保存到saveKey所在的结构中。
     * @param desKey  需要进行元操作的类型。
     * @return 1:返回元操作值。
     */
    public static Long bitOp(RedisStringCommands.BitOperation op, String saveKey, String... desKey) {
        byte[][] bytes = new byte[desKey.length][];
        for (int i = 0; i < desKey.length; i++) {
            bytes[i] = desKey[i].getBytes();
        }
        return stringRedisTemplate.execute((RedisCallback<Long>) con -> con.bitOp(op, saveKey.getBytes(), bytes));
    }

    /**
     * 对一个或多个保存二进制的字符串key进行元操作,并将结果保存到saveKey上,并返回统计之后的结果。
     *
     * @param op      元操作类型;
     * @param saveKey 元操作后将结果保存到saveKey所在的结构中。
     * @param desKey  需要进行元操作的类型。
     * @return 返回saveKey结构上value=1的所有数量值。
     */
    public static Long bitOpResult(RedisStringCommands.BitOperation op, String saveKey, String... desKey) {
        bitOp(op, saveKey, desKey);
        return bitCount(saveKey);
    }


    /**
     * guava依赖获取hash值。
     */
    private static long hash(String key) {
        Charset charset = Charset.forName("UTF-8");
        return Math.abs(Hashing.murmur3_128().hashObject(key, Funnels.stringFunnel(charset)).asInt());
    }

}

4. bitmap导致大key

4.1 原因

网上的文章,均是简单的介绍bitmap的用法,但是都存在一个很大的风险的,导致bitmap占用大内存。

例如判断活跃用户量,使用bitmap实现:网上介绍很简单,bitset 将偏移量设置为1,但是id如何转换为偏移量并没有一篇文章进行介绍。

bitmap推荐的偏移量是从1一直累加的,但是计算出的hash值为10位(10亿级别),那么占用的内存大小为239MB,若是计算出的hash值为7位(百万级别)占用的内存大小为124KB。

所以计算偏移量的时候不能无脑的进行hash得到,而是要根据系统情况(百万级别的日活、十万级别日活),进行取余计算,得到合适的偏移量。

4.2 解决方案

对数据进行分组在分片:

  1. 不仅可以避免bitmap导致大key。
  2. 避免出现范围用户太多导致查询时出现热key。
public class TestBitMap {


    public static void main(String[] args) {

        System.out.println(ONE_BITMAP_SIZE);
        System.out.println(1024*1024);
        long r1=223456679;
        BitMapKey u1 = computeUserGroup(r1, ONE_BITMAP_SIZE, SHARD_COUNT);
        System.out.println(u1);

        long r2=1234566777;
        BitMapKey u2 = computeUserGroup(r2, ONE_BITMAP_SIZE, SHARD_COUNT);
        System.out.println(u2);


        String redisKey = u2.generateKeyWithPrefix("ttt");
        System.out.println(redisKey);
    }

    // 单个bitmap占用1M内存
    // 如果useId < 100亿, 则会分到7000个分组里
    private static final int ONE_BITMAP_SIZE = 1 << 20;
    // 同一个分组里的的useId划分到20个bitmap里
    // 避免出现范围用户太多导致查询时出现热key
    private static final int SHARD_COUNT = 20;

    // 计算用户的 raw, shard, 和对应的offset
    public static BitMapKey computeUserGroup(long userId, int oneBitMapSize, int shardCount) {
        //获取组
        long groupIndex = userId / oneBitMapSize;
        //获取分片位置
        int shardIndex = Math.abs((int) (hash(userId+"") % shardCount));
        //获取(组-分片)下的offset位置
        int bitIndex = (int) (userId - groupIndex * oneBitMapSize);
        //获取到对象
        return new BitMapKey((int) groupIndex, shardIndex, bitIndex);
    }

    @Data
    public static class BitMapKey {
        /**
         * 组
         */
        private final int groupIndex;
        /**
         * 组中分片
         */
        private final int shardIndex;
        /**
         *
         */
        private final int bitIndex;

        public BitMapKey(int groupIndex, int shardIndex, int bitIndex) {
            this.groupIndex = groupIndex;
            this.shardIndex = shardIndex;
            this.bitIndex = bitIndex;
        }

        public int getBitIndex() {
            return bitIndex;
        }

        public String generateKeyWithPrefix(String prefix) {
            return String.join(":", prefix, groupIndex + "", shardIndex + "");
        }
    }

    /**
     * guava依赖获取hash值。
     */
    private static long hash(String key) {
        Charset charset = Charset.forName("UTF-8");
        return Math.abs(Hashing.murmur3_128().hashObject(key, Funnels.stringFunnel(charset)).asInt());
    }

}

依赖类:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.0.1-jre</version>
</dependency>

5. 计算日活、月活、留存率的具体方法

具体实施:使用redis的bitmap

  1. 设置一个key专门用来记录用户日活的,可以使用时间来翻滚比如1号的key为active01.
  2. 使用每个用户的唯一标识映射一个偏移量,比如使用id,这里可以把id换算成一个数字或直接使用id的二进制值作为该用户在当天是否活跃偏移量
  3. 用户登录则把该用户偏移量上的位值设置为1
  4. 每天按日期生成一个位图(bitmap)
  5. 计算日活则使用bitcount即可获得一个key的位值为1的量
  6. 计算月活(一个月内登陆的用户去重总数)即可把30天的所有bitmap做or计算,然后再计算bitcount
  7. 计算留存率(次日留存=昨天今天连续登录的人数/昨天登录的人数) 即昨天的bitmap与今天的bitmap做and计算就是连续登录的再做bitcount就得到连续登录人数,再bitcount得到昨天登录人数,就可以通过公式计算出次日留存。

文章参考:
Redis:Bitmap的setbit,getbit,bitcount,bitop等使用与应用场景

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • 基本的Redis key的操作都已经熟悉了之后,便可以开始针对Redis提供的各种可操作的数据结构进行学习和了解。...
    Yorking阅读 849评论 0 0
  • Redis key 值是二进制安全的,这意味着可以用任何二进制序列作为key值,从形如”foo”的简单字符串到一个...
    壹点零阅读 1,404评论 0 2
  • 什么是位图 位图(Bitmap)是通过一个 bit 来表示某个元素对应的值或者状态。它并不是什么新的数据结构。它的...
    youthcity阅读 2,018评论 1 51
  • 1.1 资料 ,最好的入门小册子,可以先于一切文档之前看,免费。 作者Antirez的博客,Antirez维护的R...
    JefferyLcm阅读 17,032评论 1 51
  • 1. 什么是位图 redis可以直接对数据进行位操作。 2. 实例 setbit key offset value...
    FantJ阅读 1,110评论 0 3