企业级用户签到业务解决方案,附完整代码(Redis,位图,二分)
签到系统的特点是写入的数据量大,查询次数较多,如果直接使用数据库进行单个用户单次签到信息作为一条数据进行保存,按照10w/day 的写入量,那么不到200天也就是不到七个月数据库将达到2kw的数据量,这会对查询的性能造成很大影响。同时在计算连续天数时也需要通过不断扩大连续天数进行多次查询,效率较低。
如今的的大型系统多数会采用位图的方式进行签到信息的统计,更多的借助Redis这种中间件来快速完成数据保存,具体做法是通过设定一个Hash 算法,将当前天数映射到一个从 0 开始的bit数组的一个index上,然后通过setBit(1) 的方式 将某一天的用户签到信息进行统计。
如下图,数据从右侧(低位)往左侧(高位)写入
lua
用户:**Key: zhangsan**
Value: 1----------1----------1----------1----------1----------0----------1
Index:6----------5----------4----------3----------2----------1----------0
10-09 10-08 10-07 10-06 10-05 10-04 10-03
从 3日 到 9日期间内 用户除了 4日没有签到外其他都进行了签到
日期索引映射算法
指定一个日期 S 作为index:0 对应的日期,然后将当前日期将去指定初始日期 S 计算出天数 Diff,那么index = Diff
日期映射索引的状态压缩
如果开始日期值设定的太早,比如为1970年一月一日作为开始值,那么很多的bit位都会被浪费了,毕竟如果从1970年开始统计用户的签到记录的话 总共会浪费掉 20054 bit 也就是 相当于 2506字节 大概2.4KB多一点的大小,这虽然不多,但是如果一个系统有10w用户(大型系统的往往以百万为单位),那么总共就会浪费掉 234MB的空间(好像并不是很多)那么我们其实可以通过压缩日期的方式来节省掉这234MB
其实也很简单,那就是设定前N个bit位不用来标识是否签到数据,而是用来标识用户有签到记录以来距离起始日期的天数差,我们只需要开辟16bit,其对应的十进制值就可以计算用户第一天的日期,再用当前时间减去第一天签到的日期就得到了index值
首先我们把1970年1月1日的日子作为起始日期,那么这16bit 最大是2^16-1,也就是65536天,从1970 一月一日往后推,可以一直用到2149年(根本用不完)
10w用户 从 234MB节省到 200KB
Redis 位操作实现
使用 Redis 的 SETBIT
命令设置某一天的签到状态。
r
SETBIT user:zhangsan:sign 6 1
# 用户在 10-09 签到
使用 Redis 的 GETBIT
命令获取某一天的签到状态。
r
GETBIT user:zhangsan:sign 6 # 返回 1(已签到)
# 用户在 10-09 签到的签到状态
JAVA 使用Lettuce redis 客户端 可以使用 bitfield 方法
获取用户过去64天签到天数
ini
// 读取时加上16bit偏移位,注意必须使用有符号读取64位,无符号只能读取63位,lowIndex就是当前日期对应index的前63位,因为bitfield只能从低位往高位读取
List<Long> result = cacheClient.String().bitfield(userSignKey, BitFieldArgs.Builder.get(BitFieldArgs.signed(64), 16 + lowIndex));
// 读取的64天数据结果
Long signData = result.get(0);
获取用户当天的签到数据,offset 是对于0的偏移量,注意传入时需要加上16的日志压缩位。
ini
List<Long> bitfield = cacheClient.String().bitfield(userKey, BitFieldArgs.Builder.get(BitFieldArgs.unsigned(1), offset));
设置用户的当天签到
ini
List<Long> bitfield = cacheClient.String().bitfield(userSignKey, BitFieldArgs.Builder.set(BitFieldArgs.unsigned(1), offset, 1));
用户连续签到天数算法(java)
很自然能够想到 先通过索引 往前获取 N天 然后去检查这N天的高位连续1的长度 也就是用户的连续签到的天数。
首先我们得确定N的长度,java 中 我们可以用Long进行数据的接收,一个Long 包含了 64个bit位,所以理论上来说我们能够获取前63天的签到信息
假设这个Long数值的二进制如下(左边为高位,右边为低位)
record : 1--1--1--1--0--0--1--1--1--0--0--0--...(剩余低位全为0,补齐至64位)
看图可以知道用户连续签到了四天,可以通过遍历的方式来获取签到天数。
ini
对高位1进行&操作,如果大于0则左移一位检查下一位,如果0则退出
遍历检查高位1
value:
111110100000..
&
1000000000000
=
1000000000000
下一轮:左移value
value:
111101000000..
&
100000000000
=
1000000000000
直到:
value:
010000000000..
&
100000000000..
=
000000000
退出
但是这样的缺点是效率低,有没有更快的办法呢?
其实如果有阅读过Bitmap源码的同学应该猜到了,在nextClearBit 和 nextSetBit 方法中,java可不是简单的去循环遍历每个bucket,而是通过一个巧妙的位运算与二分查找完成
首先我们直接对这个record 进行取反
csharp
record ~= record
record = 0--0--0--0--1--1--0--0--0--1--1--1--...(剩余低位全为1,补齐至64位)
观察后发现断签的天数其实就是高位1的index。快速查找高位1的位置可以使用二分查找,每次查找左半区是否大于0,如果大于0则有1,否则查找右半区
ini
int left = 0; // 最左位(最高位)
int right = 63; // 最右位(最低位)
while (left < right) {
int mid = (left + right) / 2; // 二分中点
// 检查范围 [left, mid] 是否有 1
long mask = ((1L << (mid + 1)) - 1); // 生成 [0, mid] 的掩码
long maskedRecord = record & mask; // 应用掩码屏蔽 [mid+1, 63]
if (maskedRecord != 0) { // 如果范围 [left, mid] 有 1
right = mid; // 缩小到左半部分
} else { // 如果范围 [left, mid] 没有 1
left = mid + 1; // 缩小到右半部分
}
}
return left; // 最终 left == right,返回最高位 1 的索引
- 当然,还可以直接使用无符号右移的操作不断检查左半区,步骤如下
record >>>32
获取高32位bit,判断是否==0,如果是则前导+=32,然后检查低32位,如果不是则检查高32位,检查逻辑如下- 依次检查高16位,高8位,高4位,高2位,高1位,是否为0,如果是0则加上对应的长度
ini
if (i == 0)
return 64;
if (i < 0)
return 0;
int n = 0;
// 令 x 为 record无符号右移32位,即获取高32位bit
int x = (int)(record >>> 32);
// 如果x==0,则证明左半区全为0,然后对record进行强制转换位32位int,这样会舍弃高32位bit,保留低32
if (x == 0) { n += 32; x = (int)record; }
// 接下来的步骤和上面相似,都是检查左半区是否为0,为0则检查右半区
if (x >>> 16 == 0) { n += 16; x <<= 16; }
if (x >>> 24 == 0) { n += 8; x <<= 8; }
if (x >>> 28 == 0) { n += 4; x <<= 4; }
if (x >>> 30 == 0) { n += 2; x <<= 2; }
// 32位的x右移31位即只保留了最高位,判断最高位就是最后的步骤
if ((x >>> 31) == 0) {
n += 1;
}
- 检查64位的时间复杂度为O(1)
jdk 的Long类提供了 numberOfLeadingZeros 方法可以直接获取前导0的长度,其中JDK8的代码与上面的代码是相似的,不同点在于通过提前设置n = 1 在最后 就 直接减去高32位即可返回而不需要去判断高32位的内容。
其中不难看出如果入参<0那么前导0的长度是一定为0的,这点在jdk8中并没有进行优化处理
在JDK11中,这个方法又进行了性能优化,首先补齐了小于0则直接返回0的提前判断,其次是将左半区==0的操作替换成了 与2^n 大小进行比较,其中n也是按照二分进行取值
- 对于用户签到不满64天的数据,java读取到的Long高位有填充的0,此时就不能简单的再去取反求高位1的位置了,我们需要将填充的0进行赋值为1,然后再去计算,最后将计算的值减去赋值的1即可
完整代码
ini
public class UserSignService {
@Autowired
private CacheClient cacheClient;
private static final String USER_SIGN_KEY = "USER_SIGN_KEY:";
// 判断用户是否签到
public Boolean isUserSign(String userKey, Date date) {
String userSignKey = getUserSignKey(userKey);
// 如果用户没有签到记录,那么将初始化偏移量
checkInitOffset(userKey,date);
BitFieldArgs.BitFieldType unsigned = BitFieldArgs.unsigned(1);
int offset = getDateMappingIndex(userSignKey, date) + 16;
Long bitValueFromRedis = getBitValueFromRedis(userSignKey, unsigned, offset);
return bitValueFromRedis == 1L;
}
// 为用户签到
public Boolean signUser(String userKey, Date date) {
String userSignKey = getUserSignKey(userKey);
// 如果用户没有签到记录,那么将初始化偏移量
checkInitOffset(userKey,date);
int offset = getDateMappingIndex(userSignKey, date) + 16;
List<Long> bitfield = cacheClient.String().bitfield(userSignKey, BitFieldArgs.Builder.set(BitFieldArgs.unsigned(1), offset, 1));
return true;
}
// 对用户进行清除签到
public Boolean clearUserSign(String userKey, Date date) {
String userSignKey = getUserSignKey(userKey);
List<Long> bitfield = cacheClient.String().bitfield(userSignKey, BitFieldArgs.Builder.set(BitFieldArgs.unsigned(1), getDateMappingIndex(userSignKey, date) + 16, 0));
return true;
}
// 删除用户签到记录
public Boolean delUserSign(String userKey) {
String userSignKey = getUserSignKey(userKey);
cacheClient.Key().del(userSignKey);
return true;
}
// 为用户从开始日期到结束日期统一签到(闭区间)
public Boolean signUser(String userKey, Date startDate, Date endDate) {
String userSignKey = getUserSignKey(userKey);
// 计算高位索引
int heighIndex = getDateMappingIndex(userSignKey, endDate);
// 计算低位索引
int lowIndex = getDateMappingIndex(userSignKey, startDate);
// 设置长度
int length = heighIndex - lowIndex + 1;
// 从lowIndex + 16 到 heightIndex 全部设置为1
while (length > 64){
List<Long> bitfield = cacheClient.String().bitfield(userSignKey, BitFieldArgs.Builder.set(BitFieldArgs.signed(64), lowIndex+16, -1));
length -= 64;
lowIndex += 64;
}
long val = -1L >>> (64 - length);
List<Long> bitfield = cacheClient.String().bitfield(userSignKey, BitFieldArgs.Builder.set(BitFieldArgs.unsigned(length), lowIndex+16, val));
return true;
}
// 获取用户连续签到天数
public Integer getUserContinuousSignDays(String userKey, Date date) {
String userSignKey = getUserSignKey(userKey);
int startIndex = getDateMappingIndex(userSignKey, date);
// 高位索引位置
int heighIndex = startIndex;
if(heighIndex < 0){
return 0;
}
// -1 为 64位1,如果后64为全为1则需要继续检查下64位数据,直到不是-1或者到达0索引位置
long signData = -1L;
while(signData == -1L){
// 如果高位索引位置小于0则说明所有位数已经检查完毕
if(heighIndex < 0){
break;
}
// 低位索引位置,需要注意范围,不足64则从0读取,否则从前64位开始读取
int lowIndex = heighIndex < 64 ? 0 : heighIndex - 63;
// 注意范围
int readLength = heighIndex - lowIndex == 0 ? 1 : heighIndex - lowIndex + 1;
// 读取时加上16bit偏移位
List<Long> result = cacheClient.String().bitfield(userSignKey, BitFieldArgs.Builder.get(BitFieldArgs.signed(readLength), 16 + lowIndex));
if(result == null || result.isEmpty()){
log.error("读取错误,结果为空");
return -1;
}
signData = result.get(0);
// 反转读取的bits,因为javaIO读取bit是大端模式读取,高位读取数据放低位,但是算法要求小端读取,直接反转读取位即可
// 000011001 => 000010011
signData = reverseBits(signData, readLength);
// 如果进入下一次循环则需要将高位往低位推64位
heighIndex -= 64;
}
// 还原最后一次的高位索引,用于后续判断有效位置
heighIndex+=64;
// 如果高位小于63位,则需要忽略63 ~ heighIndex 之间的bit位,将这些位设置为1
if(heighIndex < 63){
// 屏蔽高位无意义的 bit,只关注从 heighIndex 到 0 的签到状态
signData |= (~0L) << heighIndex+1;
}
// 进行取反操作,从heighIndex 向下寻找 第一个1的index 就是 最早没有签到的日期
signData = ~signData;
// 如果高位为1则连续签到天数为0
if(signData < 0){
// 如果高位往低位移动了,则前 startIndex - heighIndex 为连续签到天数,否则就是0天连续签到
return heighIndex == startIndex ? 0 : startIndex - heighIndex;
}else{
// 获取高位0的长度
int highZeroLength = Long.numberOfLeadingZeros(signData);
// 如果前面有填充1,则需要减去填充的1数量
highZeroLength = highZeroLength - (63 - heighIndex);
return heighIndex == startIndex ? highZeroLength : startIndex - heighIndex + highZeroLength;
}
}
// 获取用户
private String getUserSignKey(String userKey) {
return USER_SIGN_KEY + userKey;
}
/**
* 获取日期映射索引,如果直接通过返回值从redis获取bit,必须先加16位
* @param userKey
* @param date
* @return
*/
private int getDateMappingIndex(String userKey, Date date) {
// 获取前16位bit作为日期偏移天数
List<Long> bitfield = cacheClient.String().bitfield(userKey, BitFieldArgs.Builder.get(BitFieldArgs.unsigned(16), 0));
if(bitfield == null || bitfield.isEmpty()){
log.error("用户Key为null");
return -1;
}
// 只读取2Bytes,安全强转
int offsetDays = Math.toIntExact(bitfield.get(0));
// 计算传入日期距离1970年01月01日的天数差,再减去偏移量即为索引位置
int diff = TcDateUtils.daysBetween(new Date(0L), date);
if(diff - offsetDays < 0){
log.error("日期偏移量大于天数差,偏移量错误!偏移量: {}, 传入日期距离1970-01-01 天数差: {}", offsetDays, diff);
return -1;
}
// 高位索引位置
return diff - offsetDays;
}
private Long getBitValueFromRedis(String userKey, BitFieldArgs.BitFieldType bitFieldType, int offset) {
List<Long> bitfield = cacheClient.String().bitfield(userKey, BitFieldArgs.Builder.get(bitFieldType, offset));
if(bitfield == null || bitfield.isEmpty()){
log.error("用户Key为null, value为空,userKey:{}", userKey);
return -1L;
}
return bitfield.get(0);
}
private void checkInitOffset(String userKey, Date date){
String userSignKey = getUserSignKey(userKey);
// 如果用户没有签到记录,那么将初始化偏移量
if(!cacheClient.Key().exists(userSignKey)){
// 计算传入日期距离1970年01月01日的天数差
int diff = TcDateUtils.daysBetween(new Date(0L), date);
// 2Bytes 可以存储的范围为0 ~ 65535, 偏移量最大可到2142年
cacheClient.String().bitfield(userSignKey, BitFieldArgs.Builder.set(BitFieldArgs.unsigned(16), 0, diff));
}
}
private static long reverseBits(long x, int length) {
long result = 0;
for (int i = 0; i < length; i++) {
result <<= 1;
result |= (x >> i) & 1;
}
return result;
}
}