Flink 实时统计历史 pv、uv

Flink 实时统计 pv、uv 的博客,我已经写了三篇,最近这段时间又做了个尝试,用 sql 来计算全量数据的 pv、uv。

Stream Api 写实时、离线的 pv、uv ,除了要写代码没什么其他的障碍

SQL api 来写就有很多障碍,比如窗口没有 trigger,不能操作 状态,udf 不如 process 算子好用等

问题

预设两个场景的问题:
1. 按天统计 pv、uv
2. 在解决问题 1 的基础上,再解决历史 pv、uv 的统计

实现思路

有以下几种思路,来实现实时统计 pv、uv

  1. 直接使用 CUMULATE WINDOW 计算当日的 pv、uv
  2. 直接使用 CUMULATE WINDOW 计算当日的 pv、uv,再获取昨天的 pv,累加可以得到基于历史的 pv
  3. pv 计算同解法 2 ,uv 的计算采用 udaf,使用 bloom filter 来粗略的计算 uv
  4. pv 计算同解法 2 ,uv 的计算采用 udaf,用 redis 记录 user_id ,每次计算的时候获取 user_id 的数量即 uv
  5. pv 计算同解法 2 ,uv 的计算采用 udaf,每次启动的时候获取历史的 user_id 缓存在内存中,加上新来的 user_id 计算 uv
  6. 全局窗口,直接计算全量的 pv、uv (没意义,未实现)

注: 由于需要实时输出结果,SQL 都选用了 CUMULATE WINDOW

建表语句

建表语句只有 数据流表、输出表、lookup join 输出表

CREATE TABLE user_log
(
     user_id     VARCHAR
    ,item_id     VARCHAR
    ,category_id VARCHAR
    ,behavior    VARCHAR
    ,ts          TIMESTAMP(3)
    ,proc_time   as PROCTIME()
    ,WATERMARK FOR ts AS ts - INTERVAL '5' SECOND
) WITH (
      'connector' = 'kafka'
      ,'topic' = 'user_log'
      ,'properties.bootstrap.servers' = 'localhost:9092'
      ,'properties.group.id' = 'user_log'
      ,'scan.startup.mode' = 'latest-offset'
      ,'format' = 'json'
);

create table if not exists user_log_lookup_join(
    cal_day varchar
    ,behavior varchar
    ,pv  bigint
    ,uv  bigint
    ,PRIMARY KEY (cal_day, behavior) NOT ENFORCED
    ) with (
          'connector' = 'jdbc'
          ,'url' = 'jdbc:mysql://localhost:3306/venn'
          ,'table-name' = 'pv_uv'
          ,'username' = 'root'
          ,'password' = '123456'
          ,'scan.partition.column' = 'cal_day'
          ,'scan.partition.num' = '1'
          ,'scan.partition.lower-bound' = '0'
          ,'scan.partition.upper-bound' = '9999'
          ,'lookup.cache.max-rows' = '1000'
        -- one day, once cache, the value will not update
          ,'lookup.cache.ttl' = '86400000' -- ttl time 超过这么长时间无数据才行
    );


create table if not exists user_log_sink(
    cal_day varchar
    ,behavior varchar
    ,start_time VARCHAR
    ,end_time VARCHAR
    ,pv  bigint
    ,uv  bigint
    ,last_pv  bigint
    ,last_uv  bigint
    ,PRIMARY KEY (cal_day, behavior) NOT ENFORCED
) with (
--      'connector' = 'print'
      'connector' = 'jdbc'
      ,'url' = 'jdbc:mysql://venn:3306/venn'
      ,'table-name' = 'pv_uv'
      ,'username' = 'root'
      ,'password' = '123456'
);

思路 1

就是个简单的 CUMULATE 的 一天的窗口,统计 count/count distinct ,窗口的触发事件是 10 秒一次

sql 如下:

insert into user_log_sink
select
 date_format(window_start, 'yyyy-MM-dd') cal_day
 ,behavior
 ,date_format(window_start, 'HH:mm:ss') start_time
 , date_format(window_end, 'HH:mm:ss') end_time
 , count(user_id) pv
 , count(distinct user_id) uv
FROM TABLE(
    CUMULATE(TABLE user_log, DESCRIPTOR(ts), INTERVAL '10' SECOND, INTERVAL '1' DAY))
  GROUP BY window_start, window_end, behavior
;

结论: 这个只能实时输出当天的 pv、uv,不能计算历史的 pv、uv

思路 2

在 思路 1 的基础上,关联昨天的结果

sql 如下:

insert into user_log_sink
select
     a.cal_day
    ,a.behavior
    ,'' start_time
    ,date_format(a.proc_time, 'yyyy-MM-dd HH:mm:ss')
    ,a.pv + COALESCE(c.pv,0) -- add last
    ,a.uv
    ,c.pv last_uv
    ,c.uv last_uv
from(
    select
     date_format(window_start, 'yyyy-MM-dd') cal_day
     ,behavior
     ,max(proc_time) proc_time
     ,count(user_id) pv
     ,count(distinct user_id) uv
    FROM TABLE(
        CUMULATE(TABLE user_log, DESCRIPTOR(ts), INTERVAL '10' SECOND, INTERVAL '1' DAY))
      GROUP BY window_start, window_end, behavior
        )a
        left join user_log_lookup_join FOR SYSTEM_TIME AS OF a.proc_time AS c
                  ON  a.behavior = c.behavior
                      and udf_date_add_new(date_format(a.proc_time, 'yyyy-MM-dd HH:mm:ss'), -1) = c.cal_day
;

结论: CUMULATE 窗口计算当天的 pv、uv,加上昨天的 pv,即可拿到累加的 pv,uv 还是只有今天的(uv 的值累加没有意义)

思路 3

在思路 2 的基础上,使用 bloom filter 来计算 uv

sql 如下:

insert into user_log_sink
select
     a.cal_day
    ,a.behavior
    ,'' start_time
    ,date_format(a.ts, 'yyyy-MM-dd HH:mm:ss')
    ,a.pv + COALESCE(c.pv,0) -- add last
    ,a.uv + COALESCE(c.uv,0)
    ,c.pv last_uv
    ,c.uv last_uv
from(
    select
     date_format(window_start, 'yyyy-MM-dd') cal_day
     ,behavior
     ,max(ts) ts
     ,max(proc_time) proc_time
     ,count(user_id) pv
     ,udaf_uv_count(user_id) uv
    FROM TABLE(
        CUMULATE(TABLE user_log, DESCRIPTOR(ts), INTERVAL '10' minute, INTERVAL '1' day))
      GROUP BY window_start, window_end, behavior
        )a
        left join user_log_lookup_join FOR SYSTEM_TIME AS OF a.proc_time AS c
                  ON  a.behavior = c.behavior
                      and udf_date_add_new(date_format(a.proc_time, 'yyyy-MM-dd HH:mm:ss'), -1) = c.cal_day
;

bloom filter 在 udaf_uv_count 中实现的

public class BloomFilter extends AggregateFunction<Integer, CountAcc > {

    private final static Logger LOG = LoggerFactory.getLogger(BloomFilter.class);
    private com.google.common.hash.BloomFilter<byte[]> filter;
    @Override
    public void open(FunctionContext context) throws Exception {
        LOG.info("bloom filter open...");
        // 创建布隆过滤器对象, 预期数据量,误判率
        filter = com.google.common.hash.BloomFilter.create(
                Funnels.byteArrayFunnel(),
                1000 * 10000,
                0.01);
    }

    public void accumulate(CountAcc acc, String userId) {

        if (userId == null || userId.length() == 0) {
            return;
        }
        // parse userId to byte
        byte[] arr = userId.getBytes(StandardCharsets.UTF_8);
        // check userId exists bloom filter
        if(!filter.mightContain(arr)){
            // not exists
            filter.put(arr);
            // count ++
            acc.count += 1;
        }

    }

    @Override
    public void close() throws Exception {
    }

    @Override
    public Integer getValue(CountAcc acc) {
        // get
        return acc.count;
    }

    @Override
    public CountAcc createAccumulator() {
        CountAcc acc = new CountAcc();
        return acc;
    }

    public void merge(CountAcc acc, Iterable<CountAcc> it) {
        int last = acc.count;
        StringBuilder builder = new StringBuilder();
        for (CountAcc a : it) {
            acc.count += a.count;
        }
    }

}

结论: pv 如思路2, uv 值只能拿到当前窗口的
原因:
1. bloom filter 不能返回 uv 的数据
2. 累加器里面只有当前窗口的数据
3. udaf 里面无法获取窗口状态(开始、结束)无法用全局变量记录上一窗口数据

注: 大佬们可以自己尝试

思路 4

在思路 2 的基础上,每次将新的 user_id 放入 redis中,getValue 的时候去redis 获取全量的 user_id

SQL 如下:

insert into user_log_sink
select
     a.cal_day
    ,a.behavior
    ,'' start_time
    ,date_format(a.ts, 'yyyy-MM-dd HH:mm:ss')
    ,a.pv + COALESCE(c.pv,0) -- add last
    ,a.uv + COALESCE(c.uv,0)
    ,c.pv last_uv
    ,c.uv last_uv
from(
    select
     date_format(window_start, 'yyyy-MM-dd') cal_day
     ,behavior
     ,max(ts) ts
     ,max(proc_time) proc_time
     ,count(user_id) pv
     ,udaf_redis_uv_count('user_log_uv', user_id) uv
    FROM TABLE(
        CUMULATE(TABLE user_log, DESCRIPTOR(ts), INTERVAL '10' minute, INTERVAL '1' day))
      GROUP BY window_start, window_end, behavior
        )a
        left join user_log_lookup_join FOR SYSTEM_TIME AS OF a.proc_time AS c
                  ON  a.behavior = c.behavior
                      and udf_date_add_new(date_format(a.proc_time, 'yyyy-MM-dd HH:mm:ss'), -1) = c.cal_day
;

udf 实现如下:

/**
 * accumulate add user_id to redis
 * getValue: get all redis user_id, count the uv
 */
public class RedisUv extends AggregateFunction<Integer, Integer> {

    private final static Logger LOG = LoggerFactory.getLogger(RedisUv.class);
    // "redis://localhost"
    private String url;
    private StatefulRedisConnection<String, String> connection;
    private RedisClient redisClient;
    private RedisCommands<String, String> sync;
    private String key;

    public RedisUv(String url, String key ) {
        this.url = url;
        this.key = key;
    }

    @Override
    public void open(FunctionContext context) throws Exception {
        // connect redis
        reconnect();
    }

    public void reconnect() {
        redisClient = RedisClient.create(this.url);
        connection = redisClient.connect();
        sync = connection.sync();
    }

    public void accumulate(Integer acc, String key, String userId) {

//        if (this.key == null) {
//            this.key = key;
//        }
        int retry = 3;
        while (retry >= 1) {
            try {
                sync.hset(key, userId, "0");
                return;
            } catch (Exception e) {
                LOG.info("set redis error, retry");
                reconnect();
                retry -= 1;
            }
        }

    }

    @Override
    public Integer getValue(Integer accumulator) {
        long start = System.currentTimeMillis();
        int size = 0;
        if (this.key == null) {
            return size;
        }
        // get all userId, count size
        int retry = 3;
        while (retry >= 1) {
            try {
                size = sync.hgetall(this.key).size();
                break;
            } catch (Exception e) {
                LOG.info("set redis error, retry");
                reconnect();
                retry -= 1;
            }
        }
        long end = System.currentTimeMillis();
        LOG.info("count all cost : " + (end - start));
        return size;
    }

    @Override
    public Integer createAccumulator() {
        return 0;
    }

    public void merge(Integer acc, Iterable<Integer> it) {
        // do nothing
    }
}

结论: pv 计算如思路2,并且可以精确计算历史的 uv,但是有个严重的性能问题(docker 单机 redis,百万 user_id,计算一次耗时 500 ms 以上。随着 用户数据增多,耗时还会加长)

注: 有个问题,从 accumulate 传入的 key,在 udaf 中不是全局可见的, accumulate 和 getValue 不在一个线程中执行(甚至不在一台服务器上)

思路 5

测试了一下 100 万个数字,放在 map 中,gc 显示,用了 300+ M 的内存,直接放弃

Heap
 PSYoungGen      total 547840K, used 295484K [0x0000000715580000, 0x0000000738180000, 0x00000007c0000000)
  eden space 526336K, 52% used [0x0000000715580000,0x000000072610f248,0x0000000735780000)
  from space 21504K, 100% used [0x0000000736c80000,0x0000000738180000,0x0000000738180000)
  to   space 21504K, 0% used [0x0000000735780000,0x0000000735780000,0x0000000736c80000)
 ParOldGen       total 349696K, used 158905K [0x00000005c0000000, 0x00000005d5580000, 0x0000000715580000)
  object space 349696K, 45% used [0x00000005c0000000,0x00000005c9b2e410,0x00000005d5580000)
 Metaspace       used 15991K, capacity 16444K, committed 16512K, reserved 1062912K
  class space    used 2022K, capacity 2173K, committed 2176K, reserved 1048576K

思路 6

直接全局窗口计算pv、uv 也不太显示,首先没有不能实时输出结果,其次也没有历史值

结论

  1. 如果只要最近一段时间的,直接用 CUMULATE 窗口就可以了
  2. 统计历史的 pv,可以用当日的pv,加上历史值来计算
  3. 统计全量历史的 uv,还是 stream api 比较好,不管是用状态还是 bloom filter 这里的算法解决,都挺方便的