《玩转Redis》系列文章主要讲述Redis的基础及中高级应用,文章基于Redis 5.0.4+。本文是《玩转Redis》系列第【8】篇,最新系列文章请前往公众号“zxiaofan”查看,或百度搜索“玩转Redis zxiaofan”即可。
本文关键字:玩转Redis、签到记录、签到日历、签到领京豆、用户签到表设计、位图Bitmaps;
大纲
- 京东签到日历的产品逻辑是怎样的?
- 传统关系型数据库该如何实现?
- 表设计初级玩法(80%的人只会这么玩)
- 表设计进阶玩法(高级程序员才会的玩法)
- 查询签到情况及签到的技术实现
- 基于Redis的Bitmaps实现签到日历(瞬间提升档次)
- 什么是Bitmaps
- Bitmaps如何使用(含详细命令对比分析及示例)
- BitMap实战签到日历
- 业务总结/技术总结
1. 京东签到日历的产品逻辑
- 签到日历仅展示当月签到数据;
- 签到日历需展示最近连续签到天数;
- 假设当前日期是20200618,且20200616未签到;
- 若20200617已签到且0618未签到,则连续签到天数为1;
- 若20200617已签到且0618已签到,则连续签到天数为2;
- 连续签到天数越多,奖励越大;
- 所有用户均可签到;
- 截至2020年3月31日的12个月,京东年度活跃用户数3.87亿,同比增长24.8%,环比增长超2500万,此外,2020年3月移动端日均活跃用户数同比增长46%。
- 假设10%左右的用户参与签到,签到用户也高达3千万;
2. 传统关系型数据库下的实现方案
2.1. MySQL表设计
2.1.1 表设计初级玩法(80%的人只会这么玩)
新建一张“用户签到记录表(user_sign)”,核心字段如下:
字段英文名 | 字段中文名 |
---|---|
keyid | 数据表主键(AUTO_INCREMENT) |
user_key | 京东用户ID(全局唯一) |
sign_date | 签到日期(如20200618) |
sign_count | 连续签到天数(如2) |
- 用户签到:往此表插入一条数据,并更新连续签到天数;
- 当日重复签到:数据不新增;
- 查询当月签到情况:查询1号至今天的签到数据;
- 查询连续签到天数:查询“sign_date=今天”的数据,今天无数据则查询“sign_date=昨天”的数据;
# 查询用户小东(user_key="20200618-xxxx-xxxx-xxxx-xxxxxxxxxxxx")的连续签到天数;
# 注意:sign_date BETWEEN "2020-06-17" AND "2020-06-18" 关联的时间点是0时0分0秒,
# 所以此处SQL的时间点必须带上时分秒;
SELECT
sign_count
FROM
user_sign
WHERE
user_key = "20200618-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
AND sign_date BETWEEN "2020-06-17 00:00:00"
AND "2020-06-18 23:59:59"
ORDER BY
sign_date DESC
LIMIT 1;
签到用户量较小时这么设计或许勉强能行,但京东这个体量的用户(估算3KW签到用户,一天一条数据,一个月就是9亿数据),即使数据表按照月份分表,同时按照“用户ID”进行hash分表(数据表示例为“user_sign_202006_0”),数据存储也是巨大的挑战。关键是投入产出比太低,这种方式还是say goodbye吧。
2.1.2 表设计进阶玩法(按位存储,高级程序员才会的玩法)
初级玩法一条签到数据一条记录,占用了大量的存储空间,我们可以从这里优化一下。
- int类型占32位,足够存储一个月的签到记录;
- 已签到则对应位存1,未签到存0;
- (此处省略26个0)000101:表示1号和3号已签到;
- 一条数据直接存储一个月的签到记录,不再是存储一天的签到记录;
- 表设计按用户ID hash分表,无需按照月份分表;
- 优化后的表设计核心字段如下:
# 用户签到记录表user_sign_{h};
# 按照用户ID hash分表,h是hash值;
CREATE TABLE `user_sign_h` (
`keyid` char(42) NOT NULL DEFAULT "" COMMENT "主键(签到月份+用户ID)",
`user_key` char(36) NOT NULL DEFAULT "" COMMENT "用户ID",
`sign_month` char(6) NOT NULL DEFAULT "190001" COMMENT "签到月份",
`sign_record` int unsigned NOT NULL DEFAULT "0" COMMENT "签到记录",
`sign_count` int unsigned NOT NULL DEFAULT "0" COMMENT "连续签到天数",
`last_sign_date` char(8) NOT NULL DEFAULT "" COMMENT "上次签到日期",
PRIMARY KEY (`keyid`),
KEY `index_user_id` (`user_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
表设计或许你有以下疑问,先思考一下吧,解析见文末Tips?
- 用户ID为什么是36位的UUID,即使百亿用户也仅需要11位就够了?
- keyid为什么不使用auto_increment自增?
2.2. 查询签到情况及签到的技术实现
以下技术实现基于“表设计进阶玩法(按位存储)”;
keyid:由签到月份+用户ID生成。如用户小东(user_key="19980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx"),其2020年6月份的签到记录keyid值是"20200619980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx",前6位是年月YYYYMM,后面36位是用户ID;
2.2.1. 查询用户当月签到数据
- 由于表设计时keyid为月份+用户ID,故可直接根据keyid查询指定用户指定月份的签到数据;
- keyid不使用auto_increment自增的原因你GET到了吗。
# 查询用户当月签到数据 @zxiaofan
SELECT
sign_record
FROM
user_sign_h
WHERE
keyid = "xxxx";
2.2.2. 查询用户连续签到天数
- 由于表设计有专门存储连续签到数量的字段,故直接查询该用户当月的“连续签到天数”即可;
- 注意:如果服务器时间是当月第一天,则需要查询当月以及上个月的“连续签到天数”。若当月的“连续签到天数”为0 ,则取上个月的“连续签到天数”;
# 查询用户连续签到天数(服务器时间不是当月第一天) @zxiaofan
SELECT
sign_count
FROM user_sign_h
WHERE keyid = "当月xxxx";
# 查询用户连续签到天数(服务器时间是当月第一天)@zxiaofan
SELECT
sign_month, sign_count
FROM user_sign_h
WHERE keyid in ("当月keyid","上月keyid");
2.2.3. 签到
- 签到先确认本月是否已签到:
- 本月未签到,则新增签到数据;
- 本月已签到,则更新签到记录;
- 签到:将当前日期签到状态置为“已签到”(对应位置为1);
- 签到时需更新连续签到天数;
- 若昨天已签到,则“连续签到天数”为“当前连续签到天数”+1;
- 若昨天未签到,则“连续签到天数”置为1即可;
# 本月第一天签到SQL @zxiaofan;
# 新增一条签到数据,连续签到天数需判断上月签到记录的最后一次签到日期;
# 若上月最后一次签到是月末,则连续签到天数在上月基础上加1;
# 若上月最后一次签到不是月末,则将连续签到天数直接置为1;
INSERT INTO user_sign_h ( `keyid`, `user_key`, `sign_month`, `sign_record`, `sign_count`, `last_sign_date` )
VALUES
( "本月keyid", "用户id", "202006", 1 << 1, 业务方计算好的连续签到天数 , "20200601" );
# 本月非第一次签到SQL @zxiaofan
# 本月第x天签到,则 sign_record = sign_record | (1 << x);
# 1 << x 表示:1向左移动x位;
# 假设今日是 20200602,则昨天是 20200601;
UPDATE user_sign_h
SET sign_record = sign_record | ( 1 << 2 ),
sign_count = ( CASE last_sign_date WHEN "20200602" THEN sign_count WHEN "20200601" THEN ( sign_count + 1 ) ELSE 1 END ),
last_sign_date = "20200602"
WHERE
keyid = "10"
AND sign_month = "202006";
- 关于补签
补签需要更新对应日期的签到记录,计算并更新连续签到天数;不是本文重点具体的技术逻辑就不赘述了。
2.3. MySQL签到解决方案的注意事项
2.3.1. 并发签到如何处理?
- 从以上SQL可以看出,若本月已签到,即使重复签到,也不会影响最终的数据;
- 注意:SQL中的“last_sign_date = xxx”必须在“sign_count = xxx”之后,因为sign_count的值取决“CASE last_sign_date”的计算结果;
- 如果是本月第一次签到,则新增数据,由于新增数据的keyid是按规则生成的,所以即使非法或异常操作导致并发签到,也丝毫不会影响最终的数据;
2.3.2. MySQL签到记录解决方案的想象空间
- 从上述实现方案来看,业务逻辑、技术实现及SQL足够简单,从而单次查询/签到性能可以满足产品诉求;
- 1个用户1年最多12条记录,3KW用户一年约3.6亿条记录,假设按用户ID hash100分表,单表约360W条记录,MySQL完全能承受;
3. 基于Redis的Bitmaps实现签到日历
3.1. 为什么要使用Bitmaps
上述基于MySQL的进阶解决方案,已能满足海量用户的签到业务。但我们想再节省点存储空间,再提升响应效率呢。
Bitmaps 闪亮登场。
3.2. 什么是Bitmaps
Bit arrays (or simply bitmaps,我们可以称之为 位图 ),Bitmaps并不是一种实际的数据类型(比如Strings、Lists、Sets、Hashes这类实际的数据类型),而是基于String数据类型的按位操作。Bitmaps支持的最大位数是2^32位。
位图本质是数组,数组由多个二进制位组成,每个二进制位都对应一个偏移量(我们可以称之为 索引 )。
Bitmaps可以极大地节省存储空间,使用512M内存就可以存储多达42.9亿的字节信息(2^32 = 4,294,967,296);
Bitmaps常见应用场景:
① 各种实时分析;
② 存储大量与ID关联的布尔值,且希望极致节省空间;
比如你想统计哪个用户访问网站的天数最多;则可以在用户每天登录时将对应天数的 bit位 设置为1,使用BITCOUNT统计此用户对应的字符串中为1的位的数量,从而计算出其登录天数。
通常我们避免在Redis中使用大key,建议将大key拆分成多个小key。常规建议是单key仅存储1M信息,则可通过bit-number/M计算出key的名字,通过bit-number MOD M(MOD表示取余)计算出第几个bit位。假设 bit-number/M = 2,bit-number MOD M = 666,则对此位的操作实际是操作key名字为“xxx:2”的key,位数是第666的位。
3.3. Bitmaps如何使用
关于Bitmaps的使用其实在先前的文章中已经提及过了,可以查看玩转Redis系列文章之《玩转Redis-Redis基础数据结构及核心命令》,其中在“String位操作”这一节已经讲过。此处我们来复习一下:
【Bitmaps核心命令】:SETBIT、BITOP、GETBIT、BITCOUNT、BITFIELD、BITPOS;
3.3.1. 【Redis-Bitmaps位操作】命令简述
命令 | 功能 | 参数 |
---|---|---|
SETBIT | 指定偏移量bit位置设置值 | key offset value【0=< offset< 2^32】 |
BITOP | 对一个或多个key执行逻辑操作,并将结果保存到destkey | operation destkey key [key ...]【AND, OR, XOR, NOT】 |
GETBIT | 查询指定偏移位置的bit值 | key offset |
BITCOUNT | 统计指定字节区间bit为1的数量 | key [start end]【@LBN】 |
BITFIELD | 操作多字节位域 | key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP/SAT/FAIL] |
BITPOS | 查询指定字节区间第一个被设置成1的bit位的位置 | key bit [start] [end]【@LBN】 |
3.3.2. Bitmaps位操作命令注意事项
- 【BITOP】支持逻辑操作,且AND、或OR、异或XOR、非NOT;
- 且AND(&):同1为1,其余为0;
- 或OR(|):有1为1,同0为0;
- 异或XOR(^):不同为1,相同为0;
- 非NOT(~):1变0,0变1;
- GETBIT、SETBIT操作的是指定位,参数offset指的是二进制位偏移量;
- BITCOUNT、BITPOS操作的是字节,参数start、end指的是字节偏移量;
- BITPOS 返回的是相对于第0 bit位的偏移量,而不是相对于 参数中start的偏移量;
3.3.3. 【Redis-String位操作】命令详细对比分析如下
3.3.4. Bitmaps位操作命令示例
- SETBIT、GETBIT、BITCOUNT、BITPOS 命令示例
# 位图Bitmaps位操作命令示例 @zxiaofan
# SETBIT、GETBIT、BITCOUNT、BITPOS 命令示例
// SETBIT 命令示例
127.0.0.1:6379> setbit bitkey 2 1
(integer) 0
127.0.0.1:6379> setbit bitkey 22 1
(integer) 0
// GETBIT 命令示例
127.0.0.1:6379> getbit bitkey 0
(integer) 0
127.0.0.1:6379> getbit bitkey 2
(integer) 1
// BITCOUNT 命令示例
// BITCOUNT、BITPOS的参数start、end指的是字节偏移量;
127.0.0.1:6379> bitcount bitkey 3 22
(integer) 0
127.0.0.1:6379> bitcount bitkey 0 0
(integer) 1
127.0.0.1:6379> bitcount bitkey 2 2
(integer) 1
127.0.0.1:6379> bitcount bitkey 0 2
(integer) 2
// BITPOS 命令示例
127.0.0.1:6379> bitpos bitkey 1 0 0
(integer) 2
// BITPOS 返回的是相对于第0 bit位的偏移量
127.0.0.1:6379> bitpos bitkey 1 2 2
(integer) 22
127.0.0.1:6379> bitpos bitkey 1 20 22
(integer) -1
- bitop 命令示例
# 位图Bitmaps位操作命令示例 @zxiaofan
# bitop 命令示例
127.0.0.1:6379> setbit bkey1 0 1
(integer) 0
127.0.0.1:6379> setbit bkey1 1 1
(integer) 0
127.0.0.1:6379> setbit bkey1 5 1
(integer) 0
127.0.0.1:6379> setbit bkey2 0 1
(integer) 0
127.0.0.1:6379> setbit bkey2 3 1
(integer) 0
127.0.0.1:6379> setbit bkey3 1 1
(integer) 0
// bitop AND
127.0.0.1:6379> bitop AND dkey1 bkey1 bkey2 bkey3
(integer) 1
127.0.0.1:6379> getbit dkey1 0
(integer) 0
127.0.0.1:6379> getbit dkey1 1
(integer) 0
127.0.0.1:6379> get dkey1
"x00"
127.0.0.1:6379> bitop AND dkey1 bkey1 bkey2
(integer) 1
127.0.0.1:6379> getbit dkey1 0
(integer) 1
127.0.0.1:6379> getbit dkey1 3
(integer) 0
// bitop XOR
127.0.0.1:6379> bitop XOR dkey1 bkey1 bkey2
(integer) 1
127.0.0.1:6379> getbit dkey1 0
(integer) 0
127.0.0.1:6379> getbit dkey1 5
3.4. Bitmaps实战签到日历
3.4.1. 签到场景下的Bitmaps设计
1M数据可以存储1,048,576位(1 * 1024 * 1024 = 1,048,576),可以存储2870年的数据(1,048,57 / 365.25 = 2870.84)。
所以我们使用1个key即可完全储存一个用户的签到数据。redis的key设计为 sign:user_key,value存储签到记录的位数组。
3.4.2. Bitmaps实现用户签到
首先明确第0位表示哪一天的数据(数据基点),比如签到产品是2000年1月1日上线的(数据基点就可以是2000年1月1日),那么第0位就表示2000年1月1日的签到记录。想要记录2000年1月3日的签到记录,则先计算此时间点和数据基点的差值(差值为2),则2000年1月3日的签到记录将存储在第2位。其他日期以此类推。
# 指定日期签到,时间复杂度O(1) @zxiaofan
127.0.0.1:6379> setbit sign:user_key 2 1
3.4.3. Bitmaps查询签到情况
通过get命令查询指定用户的所有签到记录,然后在内存中计数即可。指定时间段的签到情况或者连续签到天数均可计算。
# 查询指定key的所有签到数据,时间复杂度O(1) @zxiaofan
127.0.0.1:6379> get sign:user_key
// 查询指定日期是否签到
// 先计算次日期与数据基点的差值x
127.0.0.1:6379> get sign:user_key x
3.4.4. Bitmaps实现签到业务总结
从上述来看,使用位图Bitmaps实现签到业务场景相对简单很大,不必考虑跨月等问题,而且占用的存储空间也极小。那么我们还有优化空间吗?
目前是1个用户仅1条记录,如果产品设计上不会存在跨年数据的操作,是否可考虑将签到数据按年存储呢,历年数据在持久化后从Redis中清除从而节省Redis内存空间。当然不要为了节省而拆分,如果导致业务逻辑变复杂,就得不偿失了。
4. 总结
4.1. 业务分析
- 百亿用户也仅需要11位就够存储了,用户ID为什么是36位的UUID呢?
技术上11位的确足够了,但技术都是为业务服务的。如果使用简单的数字,则竞争对手就可以知道你的真实用户数量、用户增量情况,这在商业上是肯定不允许的。
- 基于MySQL实现签到业务,如果是本月第一天签到,“连续签到天数”为什么由业务方计算好,而不是SQL直接实现?
业务逻辑还是业务方做,在保证数据准确性的前提下,数据库逻辑尽量简单。
- keyid为什么不使用auto_increment自增?
auto_increment自增的确简单省事,但keyid自行设计为月份+用户ID,直接根据keyid查询指定用户指定月份的签到数据,这样不香吗。
- 按照这种思路就能自己做个“京东签到领京豆”吗?
只能说可以实现以上产品逻辑,京东领京豆的实际产品逻辑更加复杂,比如,京东签到领京豆有个页面可以看到“京豆领取明细”,包含精确到秒级别的领取时间,这点以上文章并未涉及,当然这也不是本文的重点。
每个产品的背后都有着产品经理充分调研用户需求、业务需求,架构、技术、运营等人员的通力合作。
心存敬畏。
- 为何鲜有APP展示用户一年的签到记录?
京东的签到日历仅展示了当前月份的数据,支付宝会员签到的最大连续签到天数是7天,CSDN的签到次数仅保留3个月。
为何不展示一年的数据呢?在商言商,不展示的重要原因当然是商业价值不足,投入产出比不高。技术上可以实现,但技术需要为业务服务、为产品服务。
4.2. 技术分析
- Bitmaps最大长度位数是多少?
由于String数据类型的最大长度是512M,所以String支持的位数是2^32位。512M表示字节
- Bitmaps可以支持超过512M的数据吗?
Strings的最大长度是512M,还能存更大的数据?当然不能,但是我们可以换种实现思路,文中其实已提及,我们回顾下:将大key拆分成多个小key。常规建议是单key仅存储1M信息,则可通过bit-number/M计算出key的名字,通过bit-number MOD M(MOD表示取余)计算出第几个bit位。假设 bit-number/M = 2,bit-number MOD M = 666,则对此位的操作实际是操作key名字为“xxx:2”的key,位数是第666的位。
按照这种思路,存储的大小完全不受限啦。
玩转Redis系列文章:
《玩转Redis-老板带你深入理解分布式锁》
《玩转Redis-如何高效访问Redis中的海量数据》
《玩转Redis-高级程序员必知的Key命令》
《玩转Redis-研发也应该知道的Connection命令》
《玩转Redis-Redis高级数据结构及核心命令-ZSet》
《玩转Redis-Redis基础数据结构及核心命令》
《玩转Redis-Redis安装、后台启动、卸载》
祝君好运!
Life is all about choices!
将来的你一定会感激现在拼命的自己!
【CSDN】【GitHub】【OSCHINA】【掘金】【语雀】【微信公众号】