玩转Redis-京东签到领京豆如何实现

《玩转Redis》系列文章主要讲述Redis的基础及中高级应用,文章基于Redis 5.0.4+。本文是《玩转Redis》系列第【8】篇,最新系列文章请前往公众号“zxiaofan”查看,或百度搜索“玩转Redis zxiaofan”即可。

本文关键字:玩转Redis、签到记录、签到日历、签到领京豆、用户签到表设计、位图Bitmaps;

大纲

  • 京东签到日历的产品逻辑是怎样的?
  • 传统关系型数据库该如何实现?
    • 表设计初级玩法(80%的人只会这么玩
    • 表设计进阶玩法(高级程序员才会的玩法
    • 查询签到情况及签到的技术实现
  • 基于Redis的Bitmaps实现签到日历(瞬间提升档次
    • 什么是Bitmaps
    • Bitmaps如何使用(含详细命令对比分析及示例)
  • BitMap实战签到日历
  • 业务总结/技术总结

1. 京东签到日历的产品逻辑

玩转Redis-京东签到领京豆如何实现_第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位操作】命令详细对比分析如下

玩转Redis-京东签到领京豆如何实现_第2张图片

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表示字节长度,换算成位需要乘以8,即512 * 2^10 * 2^10 * 8=2^32;

  • 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】【掘金】【语雀】【微信公众号】
欢迎订阅zxiaofan的微信公众号,扫码或直接搜索zxiaofan


你可能感兴趣的:(玩转Redis)