Flink 实时统计 pv、uv 的博客,我已经写了三篇,最近这段时间又做了个尝试,用 sql 来计算全量数据的 pv、uv。
Stream Api 写实时、离线的 pv、uv ,除了要写代码没什么其他的障碍
SQL api 来写就有很多障碍,比如窗口没有 trigger,不能操作 状态,udf 不如 process 算子好用等
问题
预设两个场景的问题:
1. 按天统计 pv、uv
2. 在解决问题 1 的基础上,再解决历史 pv、uv 的统计
实现思路
有以下几种思路,来实现实时统计 pv、uv
- 直接使用 CUMULATE WINDOW 计算当日的 pv、uv
- 直接使用 CUMULATE WINDOW 计算当日的 pv、uv,再获取昨天的 pv,累加可以得到基于历史的 pv
- pv 计算同解法 2 ,uv 的计算采用 udaf,使用 bloom filter 来粗略的计算 uv
- pv 计算同解法 2 ,uv 的计算采用 udaf,用 redis 记录 user_id ,每次计算的时候获取 user_id 的数量即 uv
- pv 计算同解法 2 ,uv 的计算采用 udaf,每次启动的时候获取历史的 user_id 缓存在内存中,加上新来的 user_id 计算 uv
- 全局窗口,直接计算全量的 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 也不太显示,首先没有不能实时输出结果,其次也没有历史值
结论
- 如果只要最近一段时间的,直接用 CUMULATE 窗口就可以了
- 统计历史的 pv,可以用当日的pv,加上历史值来计算
- 统计全量历史的 uv,还是 stream api 比较好,不管是用状态还是 bloom filter 这里的算法解决,都挺方便的
完整代码参考:flink sqlSubmit
欢迎关注Flink菜鸟公众号,会不定期更新Flink(开发技术)相关的推文