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

京东签到日历的产品逻辑

  • 签到日历仅展示当月签到数据
  • 签到日历需展示最近连续签到天数
    • 假设当前日期是20200618,且20200616未签到
    • 若20200617已签到且0618未签到,则连续签到天数为1
    • 若20200617已签到且0618已签到,则连续签到天数为2
  • 连续签到天数越多,奖励越大
  • 所有用户均可签到
    • 截至2020年3月31日的12个月,京东年度活跃用户数3.87亿,同比增长24.8%,环比增长超2500万,此外,2020年3月移动端日均活跃用户数同比增长46%
    • 假设10%左右的用户参与签到,签到用户也高达3千万

传统关系型数据库下的实现方案

MySQL表设计

表设计初级玩法(80%的人只会这么玩)

新建一张“用户签到记录表(user_sign)”,核心字段如下:

字段英文名 字段中文名
keyid 数据表主键(AUTO_INCREMENT)
user_key 京东用户ID(全局唯一)
sign_date 签到日期(如20200618)
sign_count 连续签到天数
  • 用户签到:往此表插入一条数据,并更新连续签到天数
  • 当日重复签到:数据不新增
  • 查询当月签到情况:查询1号至今天的签到数据
  • 查询连续签到天数:查询“sign_date=今天”的数据,今天无数据则查询“sign_date=昨天”的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 查询用户小东(user_key="20200618-xxxx-xxxx-xxxx-xxxxxxxxxxxx")的连续签到天数
# 注意:sign_date BETWEEN '2020-06-17' AND '2020-06-18' 关联的时间点是000
# 所以此处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吧。

表设计进阶玩法(按位存储,高级程序员才会的玩法)

初级玩法一条签到数据一条记录,占用了大量的存储空间,我们可以从这里优化一下。

  • int类型占32位,足够存储一个月的签到记录
  • 已签到则对应位存1,未签到存0
    • (此处省略26个0)000101:表示1号和3号已签到
  • 一条数据直接存储一个月的签到记录,不再是存储一天的签到记录
  • 表设计按用户ID hash分表,无需按照月份分表
  • 优化后的表设计核心字段如下:
1
2
3
4
5
6
7
8
9
10
11
12
# 用户签到记录表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_general_ci;

表设计或许你有以下疑问,先思考一下吧,解析见文末Tips?

  • 用户ID为什么是36位的UUID,即使百亿用户也仅需要11位就够了?
  • keyid为什么不使用auto_increment自增?

查询签到情况及签到的技术实现

以下技术实现基于“表设计进阶玩法(按位存储)”

keyid:由签到月份+用户ID生成。如用户小东(user_key=”19980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx”),其2020年6月份的签到记录keyid值是”20200619980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx”,前6位是年月YYYYMM,后面36位是用户ID

查询用户当月签到数据

  • 由于表设计时keyid为月份+用户ID,故可直接根据keyid查询指定用户指定月份的签到数据
  • keyid不使用auto_increment自增的原因你GET到了吗
1
2
3
4
5
6
7
# 查询用户当月签到数据
SELECT
sign_record
FROM
user_sign_h
WHERE
keyid = 'xxxx';

查询用户连续签到天数

  • 由于表设计有专门存储连续签到数量的字段,故直接查询该用户当月的“连续签到天数”即可
  • 注意:如果服务器时间是当月第一天,则需要查询当月以及上个月的“连续签到天数”。若当月的“连续签到天数”为0 ,则取上个月的“连续签到天数”
1
2
3
4
5
6
# 查询用户连续签到天数(服务器时间不是当月第一天)

SELECT
sign_count
FROM user_sign_h
WHERE keyid = '当月xxxx';
1
2
3
4
5
6
# 查询用户连续签到天数(服务器时间是当月第一天)

SELECT
sign_month, sign_count
FROM user_sign_h
WHERE keyid in ('当月keyid', '上月keyid');

签到

  • 签到先确认本月是否已签到:
    • 本月未签到,则新增签到数据
    • 本月已签到,则更新签到记录
  • 签到:将当前日期签到状态置为“已签到”(对应位置为1)
  • 签到时需更新连续签到天数:
    • 若昨天已签到,则“连续签到天数”为“当前连续签到天数”+1
    • 若昨天未签到,则“连续签到天数”置为1即可
1
2
3
4
5
6
7
8
# 本月第一天签到SQL
# 新增一条签到数据,连续签到天数需判断上月签到记录的最后一次签到日期
# 若上月最后一次签到是月末,则连续签到天数在上月基础上加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' );
1
2
3
4
5
6
7
8
9
10
11
12
# 本月非第一次签到SQL
# 本月第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 = '本月keyid'
AND sign_month = '202006';
  • 关于补签

补签需要更新对应日期的签到记录,计算并更新连续签到天数;不是本文重点具体的技术逻辑就不赘述了。

MySQL签到解决方案的注意事项

并发签到如何处理?

  • 从以上SQL可以看出,若本月已签到,即使重复签到,也不会影响最终的数据
  • 注意:SQL中的last_sign_date = xxx必须在sign_count = xxx之后,因为sign_count的值取决CASE last_sign_date的计算结果
  • 如果是本月第一次签到,则新增数据,由于新增数据的keyid是按规则生成的,所以即使非法或异常操作导致并发签到,也丝毫不会影响最终的数据

MySQL签到记录解决方案的想象空间

  • 从上述实现方案来看,业务逻辑、技术实现及SQL足够简单,从而单次查询/签到性能可以满足产品诉求
  • 1个用户1年最多12条记录,3KW用户一年约3.6亿条记录,假设按用户ID hash100分表,单表约360W条记录,MySQL完全能承受

基于Redis的Bitmaps实现签到日历

为什么要使用Bitmaps

上述基于MySQL的进阶解决方案,已能满足海量用户的签到业务。但我们想再节省点存储空间,再提升响应效率呢。Bitmaps 闪亮登场。

什么是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/M计算出key的名字,通过bit MOD M(MOD表示取余)计算出第几个bit位。假设bit/M = 2bit MOD M = 666,则对此位的操作实际是操作key名字为xxx:2的key,位数是第666的位。

Bitmaps如何使用

Bitmaps核心命令:SETBIT、BITOP、GETBIT、BITCOUNT、BITFIELD、BITPOS

Bitmaps位操作命令简述

命令 功能 参数
SETBIT 指定偏移量bit位置设置值 SETBIT key offset value[0 =< offset < 2^32]
BITOP 对一个或多个key执行逻辑操作,并将结果保存到destkey BITOP [AND, OR, XOR, NOT] destkey key [key …]
GETBIT 查询指定偏移位置的bit值 GETBIT key offset
BITCOUNT 统计指定字节区间bit为1的数量 BITCOUNT key [start] [end]
BITFIELD 操作多字节位域 BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP/SAT/FAIL]
BITPOS 查询指定字节区间第一个被设置成1的bit位的位置 BITPOS key bit [start] [end]

Redis-String位操作

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的偏移量

Bitmaps位操作命令示例

  • SETBIT、GETBIT、BITCOUNT、BITPOS 命令示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 位图Bitmaps位操作命令示例
# 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 命令示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 位图Bitmaps位操作命令示例
# 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

Bitmaps实战签到日历

签到场景下的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存储签到记录的位数组

Bitmaps实现用户签到

首先明确第0位表示哪一天的数据(数据基点),比如签到产品是2000年1月1日上线的(数据基点就可以是2000年1月1日),那么第0位就表示2000年1月1日的签到记录。想要记录2000年1月3日的签到记录,则先计算此时间点和数据基点的差值(差值为2),则2000年1月3日的签到记录将存储在第2位。其他日期以此类推。

1
2
3
# 指定日期签到,时间复杂度O(1)

127.0.0.1:6379> setbit sign:user_key 2 1

Bitmaps查询签到情况

通过get命令查询指定用户的所有签到记录,然后在内存中计数即可。指定时间段的签到情况或者连续签到天数均可计算。

1
2
3
4
5
6
7
# 查询指定key的所有签到数据,时间复杂度O(1)

127.0.0.1:6379> get sign:user_key

// 查询指定日期是否签到
// 先计算次日期与数据基点的差值x
127.0.0.1:6379> get sign:user_key x

Bitmaps实现签到业务总结

从上述来看,使用位图Bitmaps实现签到业务场景相对简单很多,不必考虑跨月等问题,而且占用的存储空间也极小。那么我们还有优化空间吗?

目前是1个用户仅1条记录,如果产品设计上不会存在跨年数据的操作,是否可考虑将签到数据按年存储呢,历年数据在持久化后从Redis中清除从而节省Redis内存空间。当然不要为了节省而拆分,如果导致业务逻辑变复杂,就得不偿失了。

总结

业务分析

  • 百亿用户也仅需要11位就够存储了,用户ID为什么是36位的UUID呢?

技术上11位的确足够了,但技术都是为业务服务的。如果使用简单的数字,则竞争对手就可以知道你的真实用户数量、用户增量情况,这在商业上是肯定不允许的。

  • 基于MySQL实现签到业务,如果是本月第一天签到,“连续签到天数”为什么由业务方计算好,而不是SQL直接实现?

业务逻辑还是业务方做,在保证数据准确性的前提下,数据库逻辑尽量简单。

  • keyid为什么不使用auto_increment自增?

auto_increment自增的确简单省事,但keyid自行设计为月份+用户ID,直接根据keyid查询指定用户指定月份的签到数据,这样不香吗。

  • 按照这种思路就能自己做个“京东签到领京豆”吗?

只能说可以实现以上产品逻辑,京东领京豆的实际产品逻辑更加复杂,比如,京东签到领京豆有个页面可以看到“京豆领取明细”,包含精确到秒级别的领取时间,这点以上文章并未涉及,当然这也不是本文的重点。

每个产品的背后都有着产品经理充分调研用户需求、业务需求,架构、技术、运营等人员的通力合作。心存敬畏。

  • 为何鲜有APP展示用户一年的签到记录?

京东的签到日历仅展示了当前月份的数据,支付宝会员签到的最大连续签到天数是7天,CSDN的签到次数仅保留3个月。

为何不展示一年的数据呢?在商言商,不展示的重要原因当然是商业价值不足,投入产出比不高。技术上可以实现,但技术需要为业务服务、为产品服务。

技术分析

  • Bitmaps最大长度位数是多少?

由于String数据类型的最大长度是512M,所以String支持的位数是2^32位。512M表示字节<Byte>长度,换算成位需要乘以8,即512 * 2^10 * 2^10 * 8 = 2^32

  • Bitmaps可以支持超过512M的数据吗?

Strings的最大长度是512M,还能存更大的数据?当然不能,但是我们可以换种实现思路,文中其实已提及,我们回顾下:将大key拆分成多个小key。常规建议是单key仅存储1M信息,则可通过bit/M计算出key的名字,通过bit MOD M(MOD表示取余)计算出第几个bit位。假设bit/M = 2bit MOD M = 666,则对此位的操作实际是操作key名字为xxx:2的key,位数是第666的位。

按照这种思路,存储的大小完全不受限啦。

转载至:https://my.oschina.net/zxiaofan/blog/4358220

Powered by AppBlog.CN     浙ICP备14037229号

Copyright © 2012 - 2021 APP开发技术博客 All Rights Reserved.

访客数 : | 访问量 :