《玩转Redis》系列文章主要讲述Redis的基础及中高级应用,文章基于Redis 5.0.4+。本文是《玩转Redis》系列第【8】篇,最新系列文章请前往公众号“zxiaofan”查看,或百度搜索“玩转Redis zxiaofan”即可。
本文关键字:玩转Redis、签到记录、签到日历、签到领京豆、用户签到表设计、位图Bitmaps;
大纲
新建一张“用户签到记录表(user_sign)”,核心字段如下:
字段英文名 | 字段中文名 |
---|---|
keyid | 数据表主键(AUTO_INCREMENT) |
user_key | 京东用户ID(全局唯一) |
sign_date | 签到日期(如20200618) |
sign_count | 连续签到天数(如2) |
# 查询用户小东(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吧。
初级玩法一条签到数据一条记录,占用了大量的存储空间,我们可以从这里优化一下。
# 用户签到记录表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?
以下技术实现基于“表设计进阶玩法(按位存储)”;
keyid:由签到月份+用户ID生成。如用户小东(user_key=“19980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx”),其2020年6月份的签到记录keyid值是"20200619980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx",前6位是年月YYYYMM,后面36位是用户ID;
# 查询用户当月签到数据 @zxiaofan
SELECT
sign_record
FROM
user_sign_h
WHERE
keyid = 'xxxx';
# 查询用户连续签到天数(服务器时间不是当月第一天) @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');
# 本月第一天签到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';
补签需要更新对应日期的签到记录,计算并更新连续签到天数;不是本文重点具体的技术逻辑就不赘述了。
上述基于MySQL的进阶解决方案,已能满足海量用户的签到业务。但我们想再节省点存储空间,再提升响应效率呢。
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的位。
关于Bitmaps的使用其实在先前的文章中已经提及过了,可以查看玩转Redis系列文章之《玩转Redis-Redis基础数据结构及核心命令》,其中在“String位操作”这一节已经讲过。此处我们来复习一下:
【Bitmaps核心命令】:SETBIT、BITOP、GETBIT、BITCOUNT、BITFIELD、BITPOS;
命令 | 功能 | 参数 |
---|---|---|
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】 |
# 位图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
# 位图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
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存储签到记录的位数组。
首先明确第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
通过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
从上述来看,使用位图Bitmaps实现签到业务场景相对简单很大,不必考虑跨月等问题,而且占用的存储空间也极小。那么我们还有优化空间吗?
目前是1个用户仅1条记录,如果产品设计上不会存在跨年数据的操作,是否可考虑将签到数据按年存储呢,历年数据在持久化后从Redis中清除从而节省Redis内存空间。当然不要为了节省而拆分,如果导致业务逻辑变复杂,就得不偿失了。
技术上11位的确足够了,但技术都是为业务服务的。如果使用简单的数字,则竞争对手就可以知道你的真实用户数量、用户增量情况,这在商业上是肯定不允许的。
业务逻辑还是业务方做,在保证数据准确性的前提下,数据库逻辑尽量简单。
auto_increment自增的确简单省事,但keyid自行设计为月份+用户ID,直接根据keyid查询指定用户指定月份的签到数据,这样不香吗。
只能说可以实现以上产品逻辑,京东领京豆的实际产品逻辑更加复杂,比如,京东签到领京豆有个页面可以看到“京豆领取明细”,包含精确到秒级别的领取时间,这点以上文章并未涉及,当然这也不是本文的重点。
每个产品的背后都有着产品经理充分调研用户需求、业务需求,架构、技术、运营等人员的通力合作。
心存敬畏。
京东的签到日历仅展示了当前月份的数据,支付宝会员签到的最大连续签到天数是7天,CSDN的签到次数仅保留3个月。
为何不展示一年的数据呢?在商言商,不展示的重要原因当然是商业价值不足,投入产出比不高。技术上可以实现,但技术需要为业务服务、为产品服务。
由于String数据类型的最大长度是512M,所以String支持的位数是2^32位。512M表示字节长度,换算成位需要乘以8,即512 * 2^10 * 2^10 * 8=2^32;
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】【掘金】【语雀】【微信公众号】