基于Redis bitmaps人群圈群

由于基于bitmap技术的圈群场景在Clickhouse和Doris的压测表现不是很理想,查阅了资料发现很少有文章提到bitmap在高并发人群圈选的性能问题,难道钱能解决的问题就不是问题了?由于硬件资源有限只能通过工程去弥补这个问题,于是我做了一系列的尝试和测试,有了以下的一些测试和演进方案。

测试方案

方案1:基于SQL哈希,缓存结果

这个方式能够解决同一SQL的多次查询,如果遇到真实的高并发场景,依旧还是无法真正的解决问题。
如果是模拟真实场景下的压力测试,规则中的值应该是随机生成的,这种方案不攻自破了。

方案2:使用内存表

既然频繁从硬盘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 的结果,我们可以看到主要的查询开销来自序列化。

基于Redis bitmaps人群圈选 v0.1

Clickhouse或者Doris如果在高并发场景下频繁的访问磁盘进行I/O操作并且又有bitmap对CPU计算的依赖,CPU基本上都会飙升,导致TPS下降,即使使用内存表还是无法直接解决CPU占用过高的问题。
由于之前做人群名单导出的时候,使用过Redis进行存储RoaringBitmap的结果,但是通过二进制的方式而非使用Redis bitmaps。
受到文章画像系统人群服务数据存储架构的演进与创新的启发,阅读了下Redis bitmaps文档才发现Redis bitmaps支持的操作,基本上可以完全支持人群圈选,如果要基于Redis bitmaps做人群圈选,需要使用Redis bitmaps方式进行存储。
参考文档:Redis bitmaps

在开始验证之前就遇到了一个痛点问题:

使用SETBIT方式保存结果过慢

由于使用SETBIT方式保存结果过慢,差点让我放弃了这个想法,最终通过参考这篇文章解决了这个问题。
redis bitmap数据结构之java对等操作

单元测试方案

CrowdSelector.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();
    }
}

CrowdSelectorTest.java
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);
    }
}

TagDataGenerator.java
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已经能够缓解一部分数据库的压力了,可以解决规则全部为码值类型的标签的圈群。
但是目前还可能出现几个问题:

  • 随着规则的增多,bitop调用的次数也会增多,频繁的交互,会降低性能
  • 高基维的标签(比如数值类型)如果使用bitmap方式存储,会占据大量内存存储空间,根本没有办法使用

就以上两个问题我思考了几个解决的思路,目前还没有进行验证

  • 使用lua脚本方式解决频繁交互问题
  • 在redis中使用BSI技术来存储高基维的标签

参考文章

画像系统人群服务数据存储架构的演进与创新

bitmap技术解析:redis与roaringBitmap

ClickHouse BSI与字典服务在B站商业化DMP中的应用实践

使用 Range-Encoded Bit-Slice Indexes 解决 Bitmap 范围查询和高基维问题

Redis bitmaps

画像分析 - BSI优化方案(Beta)

你可能感兴趣的:(大数据,redis,大数据,java,clickhouse)