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

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

功能分析

对于用户签到数据,如果直接采用数据库存储,当出现高并发访问时,对数据库压力会很大,例如双十一签到活动。这时候应该采用缓存,以减轻数据库的压力,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实战篇(二)基于Bitmap实现用户签到功能的更多相关文章

  1. 利用redis的bitmap实现用户签到功能

    一.场景需求 适用场景如签到送积分.签到领取奖励等,大致需求如下: 比如签到1天送1积分,连续签到2天送2积分,3天送3积分,3天以上均送3积分等. 如果连续签到中断,则重置计数,每月初重置计数. 显 ...

  2. Redis实战篇

    Redis实战篇 1 Redis 客户端 1.1 客户端通信 原理 客户端和服务器通过 TCP 连接来进行数据交互, 服务器默认的端口号为 6379 . 客户端和服务器发送的命令或数据一律以 \r\n ...

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

    场景需求 适用场景如签到送积分.签到领取奖励等,大致需求如下: 签到1天送1积分,连续签到2天送2积分,3天送3积分,3天以上均送3积分等. 如果连续签到中断,则重置计数,每月初重置计数. 当月签到满 ...

  4. Redis位图实现用户签到功能

    场景需求 适用场景如签到送积分.签到领取奖励等,大致需求如下: 签到1天送1积分,连续签到2天送2积分,3天送3积分,3天以上均送3积分等. 如果连续签到中断,则重置计数,每月初重置计数. 当月签到满 ...

  5. Redis 实战篇:巧用Bitmap 实现亿级海量数据统计

    在移动应用的业务场景中,我们需要保存这样的信息:一个 key 关联了一个数据集合. 常见的场景如下: 给一个 userId ,判断用户登陆状态: 显示用户某个月的签到次数和首次签到时间: 两亿用户最近 ...

  6. Redis 实战篇:巧用数据类型实现亿级数据统计

    在移动应用的业务场景中,我们需要保存这样的信息:一个 key 关联了一个数据集合,同时还要对集合中的数据进行统计排序. 常见的场景如下: 给一个 userId ,判断用户登陆状态: 两亿用户最近 7 ...

  7. Redis学习笔记二 (BitMap算法分析与BitCount语法)

    Redis学习笔记二 一.BitMap是什么 就是通过一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身.我们知道8个bit可以组成一个Byte,所以bitmap本身会极大的节省 ...

  8. 我的第一个上线小程序,案例实战篇二——LayaAir游戏开始界面开发

    不知不觉我的第一个小程序已经上线一周了,uv也稳定的上升着. 很多人说我的小程序没啥用,我默默一笑,心里说:“它一直敦促我学习,敦促我进步”.我的以一个小程序初衷是经验分享,目前先把经验分享到博客园, ...

  9. Redis实战篇(一)搭建Redis实例

    今天是Redis实战系列的第一讲,先从如何搭建一个Redis实例开始. 下面介绍如何在Docker.Windows.Linux下安装. Docker下安装 1.查看可用的 Redis 版本 访问 Re ...

随机推荐

  1. ES6 Class vs ES5 constructor function All In One

    ES6 Class vs ES5 constructor function All In One ES6 类 vs ES5 构造函数 https://developer.mozilla.org/en- ...

  2. Cocos Creator 游戏开发

    Cocos Creator 游戏开发 https://www.cocos.com/products#CocosCreator 一体化编辑器: 包含了一体化.可扩展的编辑器,简化了资源管理.游戏调试和预 ...

  3. foreign language learning

    foreign language learning free online learning websites 多邻国 https://www.duolingo.com 忆术家 https://www ...

  4. Service Worker in Action

    Service Worker in Action https://caniuse.com/#feat=serviceworkers Service Workers 1 W3C Candidate Re ...

  5. ts 修改readonly参数

    readonly name = "xxx"; updateValueAndValidity(): void { // this.name = 'a'; (this as { nam ...

  6. Flutter: Dismissible 通过在指示的方向上拖动来解除的Widget

    API class _MyHomeState extends State<MyHome> { @override Widget build(BuildContext context) { ...

  7. NGK公链依靠技术创新推动数字经济产业发展

    数字经济更让人们的生活发生了翻天覆地的变化.数字经济的发展要依靠技术的创新,发展出生态新模式.同时数字经济的发展要利用新技术对传统产业进行全面的的改造升级,释放数字对经济发展的放大.倍增作用.打造数字 ...

  8. Android 比较好看的注册登录界面

    各位看官姥爷: 对于一款android手机app而言,美观的界面使得用户有好的使用体验,而一款好看的注册登录界面也会给用户好的用户体验,那么话不多说,直接上代码 首先是一款简单的界面展示 1.登陆界面 ...

  9. Elasticsearch简介、倒排索引、文档基本操作、分词器

    lucene.Solr.Elasticsearch 1.倒排序索引 2.Lucene是类库 3.solr基于lucene 4.ES基于lucene 一.Elasticsearch 核心术语 特点: 1 ...

  10. Dokcer中Mysql的数据导入导出

    导出 1.首先进入容器,输入提取数据库文件命令 mysqldump -u root -p rw 数据库名> 输出.sql,提取到当前容器 2.退出容器,进入linux:输入拷贝命令 docker ...