由于基于bitmap技术的圈群场景在Clickhouse和Doris的压测表现不是很理想,查阅了资料发现很少有文章提到bitmap在高并发人群圈选的性能问题,难道钱能解决的问题就不是问题了?由于硬件资源有限只能通过工程去弥补这个问题,于是我做了一系列的尝试和测试,有了以下的一些测试和演进方案。
这个方式能够解决同一SQL的多次查询,如果遇到真实的高并发场景,依旧还是无法真正的解决问题。
如果是模拟真实场景下的压力测试,规则中的值应该是随机生成的,这种方案不攻自破了。
既然频繁从硬盘I/O会占用CPU,那我直接使用内存表如何?经过测试最终测试效果不太理想,CPU依旧还是居高不下。
https://github.com/ClickHouse/ClickHouse/issues/6880 中提到
Through the result of callgrind could we see that the major overhead comes from the serialization.
通过 callgrind 的结果,我们可以看到主要的查询开销来自序列化。
Clickhouse或者Doris如果在高并发场景下频繁的访问磁盘进行I/O操作并且又有bitmap对CPU计算的依赖,CPU基本上都会飙升,导致TPS下降,即使使用内存表还是无法直接解决CPU占用过高的问题。
由于之前做人群名单导出的时候,使用过Redis进行存储RoaringBitmap的结果,但是通过二进制的方式而非使用Redis bitmaps。
受到文章画像系统人群服务数据存储架构的演进与创新的启发,阅读了下Redis bitmaps文档才发现Redis bitmaps支持的操作,基本上可以完全支持人群圈选,如果要基于Redis bitmaps做人群圈选,需要使用Redis bitmaps方式进行存储。
参考文档:Redis bitmaps
在开始验证之前就遇到了一个痛点问题:
由于使用SETBIT方式保存结果过慢,差点让我放弃了这个想法,最终通过参考这篇文章解决了这个问题。
redis bitmap数据结构之java对等操作
package com.example.demo.redis;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.args.BitOP;
import java.util.*;
public class CrowdSelector {
private Jedis jedis;
private List<String> tempKeys = new ArrayList<>();
public CrowdSelector(Jedis jedis) {
this.jedis = jedis;
}
public String evaluateRule(JsonNode rule) {
if (rule.has("tag")) {
return "tag:" + rule.get("tag").asText();
}
String op = rule.get("op").asText();
ArrayNode rules = (ArrayNode) rule.get("rules");
List<String> childKeys = new ArrayList<>();
for (int i = 0; i < rules.size(); i++) {
JsonNode child = rules.get(i);
childKeys.add(evaluateRule(child));
}
String resultKey = "temp:result:" + UUID.randomUUID().toString();
tempKeys.add(resultKey);
if ("AND".equalsIgnoreCase(op)) {
jedis.bitop(BitOP.AND, resultKey, childKeys.toArray(new String[0]));
} else if ("OR".equalsIgnoreCase(op)) {
jedis.bitop(BitOP.OR, resultKey, childKeys.toArray(new String[0]));
} else if ("NOT".equalsIgnoreCase(op)) {
jedis.bitop(BitOP.NOT, resultKey, childKeys.get(0));
} else {
throw new IllegalArgumentException("Unsupported operator: " + op);
}
return resultKey;
}
public long countMatchedUsers(String resultKey) {
return jedis.bitcount(resultKey);
}
public void cleanup() {
for (String key : tempKeys) {
jedis.del(key);
}
tempKeys.clear();
}
}
package com.example.demo.redis;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import redis.clients.jedis.Jedis;
public class CrowdSelectorTest {
private static Jedis jedis;
private static ObjectMapper mapper;
@BeforeAll
public static void setup() {
// 创建连接(默认端口 6379,无密码)
jedis = new Jedis("127.0.0.1", 6379);
// 若需认证
jedis.auth("123456");
// 测试连接
System.out.println("连接状态: " + jedis.ping());
mapper = new ObjectMapper();
TagDataGenerator generator = new TagDataGenerator(jedis);
// 清空标签数据
generator.deleteAllTags();
generator.generateSampleTags();
}
@AfterAll
public static void teardown() {
jedis.close();
}
@Test
public void testComplexRule() throws Exception {
// 计算耗时
long startTime = System.currentTimeMillis();
String jsonRule = "{\n" +
" \"op\": \"AND\",\n" +
" \"rules\": [\n" +
" { \"tag\": \"男性\" },\n" +
" { \"tag\": \"30岁以下\" },\n" +
" {\n" +
" \"op\": \"OR\",\n" +
" \"rules\": [\n" +
" { \"tag\": \"北京\" },\n" +
" { \"tag\": \"上海\" },\n" +
" {\n" +
" \"op\": \"NOT\",\n" +
" \"rules\": [ { \"tag\": \"黑名单\" } ]\n" +
" }\n" +
" ]\n" +
" },\n" +
" {\n" +
" \"op\": \"AND\",\n" +
" \"rules\": [\n" +
" { \"tag\": \"已购用户\" },\n" +
" { \"tag\": \"高消费\" },\n" +
" { \"tag\": \"常旅客\" },\n" +
" {\n" +
" \"op\": \"OR\",\n" +
" \"rules\": [\n" +
" { \"tag\": \"信用良好\" },\n" +
" { \"tag\": \"VIP会员\" }\n" +
" ]\n" +
" }\n" +
" ]\n" +
" },\n" +
" {\n" +
" \"op\": \"OR\",\n" +
" \"rules\": [\n" +
" { \"tag\": \"注册满1年\" },\n" +
" { \"tag\": \"注册满2年\" },\n" +
" {\n" +
" \"op\": \"NOT\",\n" +
" \"rules\": [ { \"tag\": \"投诉过多\" } ]\n" +
" }\n" +
" ]\n" +
" },\n" +
" { \"tag\": \"活跃用户\" },\n" +
" { \"tag\": \"有子女\" },\n" +
" { \"tag\": \"浏览过母婴类商品\" },\n" +
" {\n" +
" \"op\": \"NOT\",\n" +
" \"rules\": [ { \"tag\": \"退货率高\" } ]\n" +
" }\n" +
" ]\n" +
"}";
JsonNode rule = mapper.readTree(jsonRule);
CrowdSelector selector = new CrowdSelector(jedis);
String resultKey = selector.evaluateRule(rule);
long count = selector.countMatchedUsers(resultKey);
selector.cleanup();
long endTime = System.currentTimeMillis();
System.out.println("耗时: " + (endTime - startTime) + "ms");
System.out.println("匹配数量: " + count);
}
}
package com.example.demo.redis;
import redis.clients.jedis.Jedis;
import java.util.*;
public class TagDataGenerator {
private Jedis jedis;
public TagDataGenerator(Jedis jedis) {
this.jedis = jedis;
}
public void deleteAllTags() {
Set<String> keys = jedis.keys("tag:*");
if (keys != null && !keys.isEmpty()) {
jedis.del(keys.toArray(new String[0]));
}
}
public void generateSampleTags() {
int max = 10000000; // 1kw用户
Map<String, Double> tagProbabilities = new LinkedHashMap<>();
tagProbabilities.put("男性", 0.8);
tagProbabilities.put("30岁以下", 0.6);
tagProbabilities.put("北京", 0.2);
tagProbabilities.put("上海", 0.2);
tagProbabilities.put("黑名单", 0.05);
tagProbabilities.put("已购用户", 0.4);
tagProbabilities.put("高消费", 0.25);
tagProbabilities.put("常旅客", 0.15);
tagProbabilities.put("信用良好", 0.7);
tagProbabilities.put("VIP会员", 0.1);
tagProbabilities.put("注册满1年", 0.6);
tagProbabilities.put("注册满2年", 0.4);
tagProbabilities.put("投诉过多", 0.1);
tagProbabilities.put("活跃用户", 0.5);
tagProbabilities.put("有子女", 0.3);
tagProbabilities.put("浏览过母婴类商品", 0.2);
tagProbabilities.put("退货率高", 0.08);
for (Map.Entry<String, Double> entry : tagProbabilities.entrySet()) {
String tag = entry.getKey();
double probability = entry.getValue();
byte[] bitmap = generateBitmap(max, probability);
jedis.set(("tag:" + tag).getBytes(), bitmap);
}
}
// 根据给定概率生成 bitmap
private byte[] generateBitmap(int maxUserId, double probability) {
BitSet bitSet = new BitSet(maxUserId);
Random random = new Random();
for (int i = 1; i <= maxUserId; i++) {
if (random.nextDouble() < probability) {
bitSet.set(i);
}
}
byte[] bytes = bitSet.toByteArray();
convertJavaToRedisBitmap(bytes);
return bytes;
}
// 转换为 Redis 的位顺序(高位在前)
private void convertJavaToRedisBitmap(byte[] bytes) {
for (int i = 0; i < bytes.length; i++) {
byte b = bytes[i];
byte reversed = 0;
for (int j = 0; j < 8; j++) {
reversed |= ((b >> j) & 1) << (7 - j);
}
bytes[i] = reversed;
}
}
}
耗时: 481ms
匹配数量: 1445
目前为止1千万数据响应时间481ms已经能够缓解一部分数据库的压力了,可以解决规则全部为码值类型的标签的圈群。
但是目前还可能出现几个问题:
就以上两个问题我思考了几个解决的思路,目前还没有进行验证
画像系统人群服务数据存储架构的演进与创新
bitmap技术解析:redis与roaringBitmap
ClickHouse BSI与字典服务在B站商业化DMP中的应用实践
使用 Range-Encoded Bit-Slice Indexes 解决 Bitmap 范围查询和高基维问题
Redis bitmaps
画像分析 - BSI优化方案(Beta)