引言
上文我们通过使用BitMap的结构来优化了标签的占用空间,但是我们发现场景code、风险分数以及风险等级占用了很多的空间,它们的结构就不能再优化了吗?如果可以优化,我们又该怎么优化呢?下面就来聊聊笔者的两种优化方式。
优化前的结构
风险等级的结构:
- key为:risk_场景code(场景code的格式是RCPSxxxx),占用12byte
- value为数值,占用的空间和位数有关,0占1byte,50,30之类的占2bye,100,150之类的占3byte,依次类推
风险分数的结构:
- key为:risk_value_场景code,占用18byte
- value为数值,占用的空间和位数有关,0占1byte,50,30之类的占2bye,100,150之类的占3byte,依次类推
根据上图和结构分析来看:一个用户一个场景下的分数和等级存储需要占用:12+2+18+2=34byte (其中,我们value值取均值2byte来计算),而我们的场景现在有9个,后面随业务方的接入也是越来越多,再加上9000万的用户,我们来计算下用户维度占用的总空间数:34 * 9 * 90000000/1000/1024/1024=26GB,而设备维度和用户维度存储结构一样,仅仅只是当前的场景只有3个,占用的总空间为:26/3 = 8G,而且平均加一个场景会增加5G左右的内存,这还是没有算IP维度的。
总占用 | 增加一个场景占用增加量 | |
---|---|---|
用户维度 | 26G | 2.8G |
设备维度 | 8G | 2.6G |
总计 | 34G | 5.4G |
优化操作
因为一个用户对应多个场景下的分数和等级,而且由上面的空间占用计算,我们也发现Redis的key相对而言占用了不少的空间,因此我们想是将用户的各个场景code、风险分数、风险等级存储在一起,放在一起我们就得考虑如何区分每个场景的信息从而可以解析获取到,我们想了两种方案:一种是定长存储,另一种是变长存储:
定长存储:
定长的规则如下:
- 风险得分 16bit
- 场景code 12bit
- 风险等级 4bit
占用总和:16bit+12bit+4bit = 4byte
这样的话,对于我们而言一个场景信息占用4byte,加上key的大小以及当前的9个场景就是:7+ 4 * 9=43byte,所有用户占用的空间为43 * 90000000/1000/1024/1024=3.7GB,每新增一个场景的增量为:4 * 90000000/1000/1024/1024 = 0.3G
定长的编码
java
/**
* 固定长度记录:12bit场景code+4bit等级+16bit分数 = 4byte
* @param sceneScoreLevelList
* @return
*/
public static byte[] buildFixedByteArray(List<SceneScoreLevel> sceneScoreLevelList) {
byte[] bytes = new byte[sceneScoreLevelList.size() * 4];
int cursor = 0;
for (SceneScoreLevel sceneScoreLevel : sceneScoreLevelList) {
int record = (sceneScoreLevel.getSceneCode() & 0xFFF) << 20;
record |= (sceneScoreLevel.getLevel() & 0xF) << 16;
record |= sceneScoreLevel.getScore() & 0xFFFF;
bytes[cursor++] = (byte) (record >> 24);
bytes[cursor++] = (byte) (record >> 16);
bytes[cursor++] = (byte) (record >> 8);
bytes[cursor++] = (byte) record;
}
return bytes;
}
定长的解码
java
public static void getSceneScoreLevelFromFixed(byte[] bytes) {
int sceneSize = bytes.length / 4;
int cursor = 0;
for (int i = 0; i < sceneSize; i++) {
int record = (bytes[cursor++] & 0xFF) << 24;
record |= (bytes[cursor++] & 0xFF) << 16;
record |= (bytes[cursor++] & 0xFF) << 8;
record |= bytes[cursor++] & 0xFF;
int sceneCode = record >> 20;
int level = (record >> 16) & 0xF;
int score = record & 0xFFFF;
System.out.printf("scene = %d ,score = %d, level = %d \n",sceneCode,score,level);
}
}
变长存储
场景code和分数占用的二进制位,我们都使用4bit来存储,也就是最大15位即32767,具体的规则和占用空间如下
变长的规则如下:
- 场景code占用空间位数:4bit
- 分数占用空间位数:4bit
- 场景code占用空间:不固定,最小1bit,最大15bit
- 分数占用空间:不固定,最小1bit,最大15bit
- 等级占用的空间:4bit
占用总和:
- 最小占用:4+4+1+1+4=14bit
- 最大占用:4+4+15+15+4=42bit
- 目前的大多数:4+4+4+8+4=24bit(实际有很多场景是为0等级0分数的:4+4+1+1+4=14bit)
变长所有的占用空间为:
- key占用:7 * 90000000 /1000/1024/1024 = 0.6G
- value最小:14 * 9 / 8 * 90000000 /1000/1024/1024 = 1.35G
- value最大:42 * 9 / 8 * 90000000 /1000/1024/1024 = 4G
- value目前最大:24 * 9 / 8 * 90000000 /1000/1024/1024 = 2.3G
变长的编码
java
/**
* 头结构:4bit表示场景code+4bit表示分数+3bit表示等级 = 11bit
* 对于后面结构的最大bit为:
* 场景:15bit
* 分数:15bit
* 等级:7bit
* 最大占用 11bit+15bit+15bit+7bit = 6byte
* 目前:11+4+7+2=3byte
* @param sceneScoreLevelList
* @return
*/
public static byte[] buildHeaderByteArray(List<SceneScoreLevel> sceneScoreLevelList) {
BitSet bitSet = new BitSet();
Integer cursor = 0;
for (SceneScoreLevel sceneScoreLevel : sceneScoreLevelList) {
int sceneCode = sceneScoreLevel.getSceneCode();
int score = sceneScoreLevel.getScore();
int level = sceneScoreLevel.getLevel();
// sceneCode占的位数
bitSetOp(4,bitSet, getSizeInBits(sceneCode), cursor);
// score占的位数
bitSetOp(4,bitSet, getSizeInBits(score), cursor);
// level占的位数
bitSetOp(3,bitSet, getSizeInBits(level), cursor);
// 场景code
bitSetOp(getSizeInBits(sceneCode),bitSet, sceneCode,cursor);
bitSetOp(getSizeInBits(score),bitSet, score,cursor);
bitSetOp(getSizeInBits(level),bitSet, level,cursor);
}
return toByteArray(bitSet,cursor.get());
}
/**
* 将int值放到BitSet中
* @param totalBitsLength int类型值实际占用的bit位数
* @param bitSet 操作的BitSet引用
* @param value 待转换的int值
* @param cursor 全局的操作游标
*/
private static void bitSetOp(int totalBitsLength, BitSet bitSet, int value, Integer cursor) {
for (int i = 0; i < totalBitsLength; i++) {
bitSet.set(cursor++, (((value & generateLongMask(totalBitsLength)) >> (totalBitsLength - 1 -i)) & 1) == 1);
}
}
/**
* BitSet转换为byte数组 按BitSet的位数来进行生成byte数组
* @param bitSet 待转换的bitSet
* @param bitSetSize bitset实际占用的位数
* @return
*/
private static byte[] toByteArray(BitSet bitSet, int bitSetSize) {
int byteArraySize = (bitSetSize + 7) / 8;
byte[] byteArray = new byte[byteArraySize];
for (int i = 0; i < bitSetSize; i++) {
if (bitSet.get(i)) {
int byteIndex = i / 8;
int bitIndex = i % 8;
byteArray[byteIndex] |= (1 << (7 - bitIndex));
}
}
return byteArray;
}
变长的解码
java
public static BitSet getSceneScoreLevelFromVariable(byte[] bytes) {
BitSet bitSet = toBitSet(bytes);
int start = 0;
int end = start + 4 - 1;
while ((bytes.length * 8 - end) > 7) {
int sceneBitLength = getIntFromBitSet(start, end, bitSet);
start = end + 1;
end = start + 4 -1;
int scoreBitLength = getIntFromBitSet(start, end, bitSet);
start = end + 1;
end = start - 1 + 3;
int levelBitLength = getIntFromBitSet(start, end, bitSet);
start = end + 1;
end = start - 1 + sceneBitLength ;
int sceneCode = getIntFromBitSet(start, end, bitSet);
start = end + 1;
end = start - 1 + scoreBitLength ;
int score = getIntFromBitSet(start, end, bitSet);
start = end + 1;
end = start - 1 + levelBitLength ;
int level = getIntFromBitSet(start, end, bitSet);
start = end + 1;
end = start + 4 -1;
}
return bitSet;
}
/**
* 将byte数组转换为BitSet
* @param byteArray byteArray
* @return
*/
public static BitSet toBitSet(byte[] byteArray) {
BitSet bitSet = new BitSet();
for (int i = 0; i < byteArray.length * 8; i++) {
int byteIndex = i / 8;
int bitIndex = 7 - (i % 8);
if (((byteArray[byteIndex] >> bitIndex) & 0x01) == 1) {
bitSet.set(i);
}
}
return bitSet;
}
private static int getIntFromBitSet(int startIndex,int endIndex,BitSet bitSet) {
int bitLength = endIndex - startIndex + 1;
// 存储组合结果的变量
int result = 0;
for (int i = startIndex; i <= endIndex; i++) {
if (bitSet.get(i)) {
// 将对应位设置为 1
result |= (1 << (bitLength - 1 - (i - startIndex)));
}
}
return result;
}
变长优化结果展示
如上图线上一个用户的风控场景信息只占用了19byte,小于我们所说的现阶段最大27byte的,这是由于实际上一个用户有很多场景是为0等级0分数的情况,这种情况的场景只占14bit,所以现阶段的总存储也是小于2.9G的
定长 OR 变长
总体的对比如下:
不压缩 | 定长压缩 | 变长压缩 | |
---|---|---|---|
分数可表示范围 | 不限 | 0~2^16-1 | 0~2^15-1 |
场景code可表示范围 | 不限 | 0~2^12-1 | 0~2^15-1 |
等级可表示范围 | 不限 | 0~15 | 0~15 |
内存占用 | 26G | 3.7G | <2.9G |
增加一个场景的增量 | 2.8G | 0.3G | <0.26G |
虽然压缩后的场景code和分数是有限的,但是2^15-1=32767的大小已经符合我们的需求了,而且我们现实数据中有一定的比例用户在一些场景下的分数和等级为0,这样使用变长压缩是十分合适的,能节省更多的空间。定长相对于变长的优点在于用户的分数和场景code很大的时候它都只需要4byte存储,而变长则需要更多的6byte存储,但是现实我们的数据很难达到这个点,所以变长是我们最终的选择方案。
写在最后
通过两篇文章,根据风控标签和场景分数的需求和大家聊了聊关于Redis数据存储优化相关,有没有和你业务很相似的场景呢,你有没有做优化呢?或者你有没有更好的优化方式呢?希望通过阅读本文能够让你对数据存储有了新的角度,同样也欢迎你有好的优化思路在评论区交流,相互学习,相互成就。