当前位置 博文首页 > 文章内容

    Redis基于Bitmap实现用户签到功能

    作者:shunshunshun18 栏目:未分类 时间:2021-06-18 14:42:53

    本站于2023年9月4日。收到“大连君*****咨询有限公司”通知
    说我们IIS7站长博客,有一篇博文用了他们的图片。
    要求我们给他们一张图片6000元。要不然法院告我们

    为避免不必要的麻烦,IIS7站长博客,全站内容图片下架、并积极应诉
    博文内容全部不再显示,请需要相关资讯的站长朋友到必应搜索。谢谢!

    另祝:版权碰瓷诈骗团伙,早日弃暗投明。

    相关新闻:借版权之名、行诈骗之实,周某因犯诈骗罪被判处有期徒刑十一年六个月

    叹!百花齐放的时代,渐行渐远!



    很多应用上都有用户签到的功能,尤其是配合积分系统一起使用。现在有以下需求:

    • 签到1天得1积分,连续签到2天得2积分,3天得3积分,3天以上均得3积分等。
    • 如果连续签到中断,则重置计数,每月重置计数。
    • 显示用户某月的签到次数和首次签到时间。
    • 在日历控件上展示用户每月签到,可以切换年月显示。
    • ...

    功能分析

    对于用户签到数据,如果直接采用数据库存储,当出现高并发访问时,对数据库压力会很大,例如双十一签到活动。这时候应该采用缓存,以减轻数据库的压力,Redis是高性能的内存数据库,适用于这样的场景。

    如果采用String类型保存,当用户数量大时,内存开销就非常大。

    如果采用集合类型保存,例如Set、Hash,查询用户某个范围的数据时,查询效率又不高。

    Redis提供的数据类型BitMap(位图),每个bit位对应0和1两个状态。虽然内部还是采用String类型存储,但Redis提供了一些指令用于直接操作BitMap,可以把它看作一个bit数组,数组的下标就是偏移量。

    它的优点是内存开销小,效率高且操作简单,很适合用于签到这类场景。缺点在于位计算和位表示数值的局限。如果要用位来做业务数据记录,就不要在意value的值。

    Redis提供了以下几个指令用于操作BitMap:

    命令 说明 可用版本 时间复杂度
    SETBIT 对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。 >= 2.2.0 O(1)
    GETBIT 对 key 所储存的字符串值,获取指定偏移量上的位(bit)。 >= 2.2.0 O(1)
    BITCOUNT 计算给定字符串中,被设置为 1 的比特位的数量。 >= 2.6.0 O(N)
    BITPOS 返回位图中第一个值为 bit 的二进制位的位置。 >= 2.8.7 O(N)
    BITOP 对一个或多个保存二进制位的字符串 key 进行位元操作。 >= 2.6.0 O(N)
    BITFIELD BITFIELD 命令可以在一次调用中同时对多个位范围进行操作。 >= 3.2.0 O(1)

    考虑到每月要重置连续签到次数,最简单的方式是按用户每月存一条签到数据。Key的格式为 u:sign:{uid}:{yyyMM},而Value则采用长度为4个字节的(32位)的BitMap(最大月份只有31天)。BitMap的每一位代表一天的签到,1表示已签,0表示未签。

    例如 u:sign:1225:202101 表示ID=1225的用户在2021年1月的签到记录

    # 用户1月6号签到
    SETBIT u:sign:1225:202101 5 1 # 偏移量是从0开始,所以要把6减1
    
    # 检查1月6号是否签到
    GETBIT u:sign:1225:202101 5 # 偏移量是从0开始,所以要把6减1
    
    # 统计1月份的签到次数
    BITCOUNT u:sign:1225:202101
    
    # 获取1月份前31天的签到数据
    BITFIELD u:sign:1225:202101 get u31 0
    
    # 获取1月份首次签到的日期
    BITPOS u:sign:1225:202101 1 # 返回的首次签到的偏移量,加上1即为当月的某一天
    
    

    示例代码

    using StackExchange.Redis;
    using System;
    using System.Collections.Generic;
    using System.Linq;
    
    /**
    * 基于Redis Bitmap的用户签到功能实现类
    * 
    * 实现功能:
    * 1. 用户签到
    * 2. 检查用户是否签到
    * 3. 获取当月签到次数
    * 4. 获取当月连续签到次数
    * 5. 获取当月首次签到日期
    * 6. 获取当月签到情况
    */
    public class UserSignDemo
    {
        private IDatabase _db;
    
        public UserSignDemo(IDatabase db)
        {
            _db = db;
        }
    
        /**
         * 用户签到
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 之前的签到状态
         */
        public bool DoSign(int uid, DateTime date)
        {
            int offset = date.Day - 1;
            return _db.StringSetBit(BuildSignKey(uid, date), offset, true);
        }
    
        /**
         * 检查用户是否签到
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 当前的签到状态
         */
        public bool CheckSign(int uid, DateTime date)
        {
            int offset = date.Day - 1;
            return _db.StringGetBit(BuildSignKey(uid, date), offset);
        }
    
        /**
         * 获取用户签到次数
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 当前的签到次数
         */
        public long GetSignCount(int uid, DateTime date)
        {
            return _db.StringBitCount(BuildSignKey(uid, date));
        }
    
        /**
         * 获取当月连续签到次数
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 当月连续签到次数
         */
        public long GetContinuousSignCount(int uid, DateTime date)
        {
            int signCount = 0;
            string type = $"u{date.Day}";   // 取1号到当天的签到状态
    
            RedisResult result = _db.Execute("BITFIELD", (RedisKey)BuildSignKey(uid, date), "GET", type, 0);
            if (!result.IsNull)
            {
                var list = (long[])result;
                if (list.Length > 0)
                {
                    // 取低位连续不为0的个数即为连续签到次数,需考虑当天尚未签到的情况
                    long v = list[0];
                    for (int i = 0; i < date.Day; i++)
                    {
                        if (v >> 1 << 1 == v)
                        {
                            // 低位为0且非当天说明连续签到中断了
                            if (i > 0) break;
                        }
                        else
                        {
                            signCount += 1;
                        }
                        v >>= 1;
                    }
                }
            }
            return signCount;
        }
    
        /**
         * 获取当月首次签到日期
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 首次签到日期
         */
        public DateTime? GetFirstSignDate(int uid, DateTime date)
        {
            long pos = _db.StringBitPosition(BuildSignKey(uid, date), true);
            return pos < 0 ? null : date.AddDays(date.Day - (int)(pos + 1));
        }
    
        /**
         * 获取当月签到情况
         *
         * @param uid  用户ID
         * @param date 日期
         * @return Key为签到日期,Value为签到状态的Map
         */
        public Dictionary<string, bool> GetSignInfo(int uid, DateTime date)
        {
            Dictionary<string, bool> signMap = new Dictionary<string, bool>(date.Day);
            string type = $"u{GetDayOfMonth(date)}";
            RedisResult result = _db.Execute("BITFIELD", (RedisKey)BuildSignKey(uid, date), "GET", type, 0);
            if (!result.IsNull)
            {
                var list = (long[])result;
                if (list.Length > 0)
                {
                    // 由低位到高位,为0表示未签,为1表示已签
                    long v = list[0];
                    for (int i = GetDayOfMonth(date); i > 0; i--)
                    {
                        DateTime d = date.AddDays(i - date.Day);
                        signMap.Add(FormatDate(d, "yyyy-MM-dd"), v >> 1 << 1 != v);
                        v >>= 1;
                    }
                }
            }
            return signMap;
        }
    
        private static string FormatDate(DateTime date)
        {
            return FormatDate(date, "yyyyMM");
        }
    
        private static string FormatDate(DateTime date, string pattern)
        {
            return date.ToString(pattern);
        }
    
        /**
         * 构建签到Key
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 签到Key
         */
        private static string BuildSignKey(int uid, DateTime date)
        {
            return $"u:sign:{uid}:{FormatDate(date)}";
        }
    
        /**
         * 获取月份天数
         *
         * @param date 日期
         * @return 天数
         */
        private static int GetDayOfMonth(DateTime date)
        {
            if (date.Month == 2)
            {
                return 28;
            }
            if (new int[] { 1, 3, 5, 7, 8, 10, 12 }.Contains(date.Month))
            {
                return 31;
            }
            return 30;
        }
    
        static void Main(string[] args)
        {
            ConnectionMultiplexer connection = ConnectionMultiplexer.Connect("192.168.0.104:7001,password=123456");
    
            UserSignDemo demo = new UserSignDemo(connection.GetDatabase());
            DateTime today = DateTime.Now;
            int uid = 1225;
    
            { // doSign
                bool signed = demo.DoSign(uid, today);
                if (signed)
                {
                    Console.WriteLine("您已签到:" + FormatDate(today, "yyyy-MM-dd"));
                }
                else
                {
                    Console.WriteLine("签到完成:" + FormatDate(today, "yyyy-MM-dd"));
                }
            }
    
            { // checkSign
                bool signed = demo.CheckSign(uid, today);
                if (signed)
                {
                    Console.WriteLine("您已签到:" + FormatDate(today, "yyyy-MM-dd"));
                }
                else
                {
                    Console.WriteLine("尚未签到:" + FormatDate(today, "yyyy-MM-dd"));
                }
            }
    
            { // getSignCount
                long count = demo.GetSignCount(uid, today);
                Console.WriteLine("本月签到次数:" + count);
            }
    
            { // getContinuousSignCount
                long count = demo.GetContinuousSignCount(uid, today);
                Console.WriteLine("连续签到次数:" + count);
            }
    
            { // getFirstSignDate
                DateTime? date = demo.GetFirstSignDate(uid, today);
                if (date.HasValue)
                {
                    Console.WriteLine("本月首次签到:" + FormatDate(date.Value, "yyyy-MM-dd"));
                }
                else
                {
                    Console.WriteLine("本月首次签到:无");
                }
            }
    
            { // getSignInfo
                Console.WriteLine("当月签到情况:");
                Dictionary<string, bool> signInfo = new Dictionary<string, bool>(demo.GetSignInfo(uid, today));
                foreach (var entry in signInfo)
                {
                    Console.WriteLine(entry.Key + ": " + (entry.Value ? "√" : "-"));
                }
            }
        }
    }
    
    

    运行结果

     

    更多应用场景

    • 统计活跃用户:把日期作为Key,把用户ID作为offset,1表示当日活跃,0表示当日不活跃。还能使用位计算得到日活、月活、留存率等数据。
    • 用户在线状态:跟统计活跃用户一样。

    总结

    • 位图优点是内存开销小,效率高且操作简单;缺点是位计算和位表示数值的局限。
    • 位图适合二元状态的场景,例如用户签到、在线状态等场景。
    • String类型最大长度为512M。 注意SETBIT时的偏移量,当偏移量很大时,可能会有较大耗时。 位图不是绝对的好,有时可能更浪费空间。
    • 如果位图很大,建议分拆键。如果要使用BITOP,建议读取到客户端再进行位计算。

    参考资料

    基于Redis位图实现用户签到功能

    Redis 深度历险:核心原理与应用实践

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

    BITFIELD SET command is not working