spark生成roaringbitmap直接同步到clickhouse
背景
spark版本 3.1.3_2.12
clickhouse 版本 22.3.10.22
需要同步一批标签数据到达clickhouse的bitmap表
原始标签表示例为
phone | label1 | label2 | label3 | dt |
---|---|---|---|---|
131*****033 | 1 | 2024-01-01 | 100.10 | 20240801 |
131*****034 | 1 | 2024-08-01 | 100.10 | 20240801 |
... | ... | ... | ... | ... |
ck-bitmap表示例为
label | value | phone_bitmap | dt |
---|---|---|---|
label1 | 1 | [B@xxx | 20240801 |
label2 | 2024-01-01 | [B@xxx | 20240801 |
... | ... | ... | ... |
我们需要在spark中把原始标签表转换成ck-bitmap表的结构,并同步到clickhouse
转换结构
首先需要进行一次melt,虽然在spark没有melt函数,但是我们可以用stack实现;要注意标签的数据类型要一致,这里使用String兼容所有数据类型
def melt(df:DataFrame): DataFrame = {
val cols = df.columns.filter(x => x != "phone" && x != "dt")
val selectCols = "phone" +: cols.map(col => f"cast($col as String) as $col") :+ "dt"
val stackCols = cols.map(x => s"'$x',$x")
df.selectExpr(selectCols: _*)
.selectExpr("phone","dt", s"stack(${stackCols.length},${stackCols.mkString(",")}) as (label,value)")
.where("value is not null")
.selectExpr("phone", "label", "value", "dt")
}
这样我们就能把宽表(原始结构)转换成长表了
然后我们需要把phone聚合成bitmap,所以我们需要写一个自定义聚合函数(UDAF)
import org.roaringbitmap.longlong.Roaring64NavigableMap
import org.apache.spark.sql.{Encoder, Encoders}
import org.apache.spark.sql.expressions.Aggregator
object BitmapUtil {
def ser64NBitmap(bm: Roaring64NavigableMap): Array[Byte] = {
val stream = new ByteArrayOutputStream()
val dataOutput = new DataOutputStream(stream)
bm.serializePortable(dataOutput)
stream.toByteArray
}
def deSer64NBitmap(bytes: Array[Byte]): Roaring64NavigableMap = {
val bis = new ByteArrayInputStream(bytes)
val dis = new DataInputStream(bis)
val bm = Roaring64NavigableMap.bitmapOf()
bm.deserializePortable(dis)
bm
}
}
class Bitmap64NGenUDAF extends Aggregator[Long, Roaring64NavigableMap, Array[Byte]] {
override def zero: Roaring64NavigableMap = {
// 构造一个空的bitmap
Roaring64NavigableMap.bitmapOf()
}
override def reduce(b: Roaring64NavigableMap, a: Long): Roaring64NavigableMap = {
b.add(a)
b
}
override def merge(b1: Roaring64NavigableMap, b2: Roaring64NavigableMap): Roaring64NavigableMap = {
b1.or(b2)
b1
}
override def finish(reduction: Roaring64NavigableMap): Array[Byte] = {
BitmapUtil.ser64NBitmap(reduction)
}
override def bufferEncoder: Encoder[Roaring64NavigableMap] = Encoders.kryo[Roaring64NavigableMap]
override def outputEncoder: Encoder[Array[Byte]] = Encoders.BINARY
}
这个代码是参考的 https://www.cnblogs.com/baran/p/15686152.html
但是他那个代码性能太差了,进行了优化;
并且由于我的入参是11位的电话号码,所以需要转换成long,对应的bitmap实现类需要改成Roaring64NavigableMap;
注:实际上11位的电话号码是可以通过一定规则缩进UInt32的数据范围,因为电话号码有些号段是不使用的,有压缩空间.这里就不展开了.
注2:要使用Roaring64NavigableMap而不是Roaring64Bitmap,这涉及到ClickHouseBitmap的创建,如果你用Roaring64Bitmap类去创建ClickHouseBitmap就会占用大量的内存,具体原因可以看ClickHouseBitmap.wrap的源码.
melt(df).createOrReplaceTempView("cb")
spark.udf.register("gen_bitmap", udaf(new Bitmap64NGenUDAF))
var dfb = spark.sql(
"""
|select label,value,gen_bitmap(cast(phone as Long)) phone_bitmap,dt
|from cb
|group by label,value,dt
|""".stripMargin)
这样我们就得到了一张bitmap表,结果和ck上是一致的
但是此时我们不能直接同步到ck上,因为这里我们使用的是Roaring64NavigableMap类,而ck上的bitmap是有做特殊处理的;
这里参考 https://www.cnblogs.com/niutao/p/15665790.html
但是我们不需要和上述文档一样手撸一个ck-bitmap实现出来,因为clickhouse已经给我们实现好了,我们需要在maven引入clickhouse官方jdbc的all包
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.4.6</version>
<!-- use uber jar with all dependencies included, change classifier to http for smaller jar -->
<classifier>all</classifier>
<!-- <scope>provided</scope>-->
</dependency>
这个all包不仅包含了jdbc,还包含了java-client和clickhouse-data模块;
里面的com.clickhouse.data.value.ClickHouseBitmap类就是ck-bitmap在java的实现;ClickHouseBitmap类的wrap方法允许org.roaringbitmap里面的bitmap类作为入参;
我们看下clickhouse jdbc的源码:
AbstractPreparedStatement实现类InputBasedPreparedStatement的setobject,可以看出setobject是可以接收ClickHouseValue的子类ClickHouseBitmap的
@Override
public void setObject(int parameterIndex, Object x) throws SQLException {
ensureOpen();
int idx = toArrayIndex(parameterIndex);
values[idx].update(x);
flags[idx] = true;
}
// 重点是这个update方法
default ClickHouseValue update(Object value) {
if (value == null) {
return resetToNullOrEmpty();
} //省略一堆else if...
else if (value instanceof ClickHouseValue) {
return update((ClickHouseValue) value);
} else {
return updateUnknown(value);
}
}
所以我们只需要把spark的Roaring64NavigableMap转换成ClickHouseBitmap就可以了
def toCKBitmap(rb: Array[Byte]): ClickHouseBitmap = {
val bitmapB = ClickHouseBitmap.wrap(BitmapUtil.deSer64NBitmap(rb), ClickHouseDataType.UInt64)
bitmapB
}
同步到clickhouse
这里使用经典的jdbc同步方法就行,细节略;
dfb.rdd.map(phone_bitmap列转换为ClickHouseBitmap).foreachPartition(jdbc批量写入ck)
还有一件事
看看ClickHouseBitmap里的这个两个方法,可以了解到一些重点
- ClickHouseBitmap类内部也是使用的RoaringBitmap,而入参是Roaring64Bitmap时用的Roaring64NavigableMap.
- ClickHouseBitmap在序列化时会序列化为CK服务端认可的形式,其中需要把内部的Roaring64NavigableMap也序列化,他调用了一个类的静态变量.SERIALIZATION_MODE这个很重要,这个静态变量在较早的RoaringBitmap在spark使用的版本(在$SPARK_HOME/jars)0.9.0是没有的,会产生版本冲突!
- 通过代码可知,我们在数据序列化之前(比如同步或保存),需要把SERIALIZATION_MODE配置成SERIALIZATION_MODE_PORTABLE,这个是较新的序列化方法;SERIALIZATION_MODE_LEGACY是默认的,也是老的序列化方法,如果我们使用0.9.0版本序列化为bytes并保存到hive表中,那将其读取出来时需要配置成SERIALIZATION_MODE_LEGACY读取.
public static ClickHouseBitmap wrap(Object bitmap, ClickHouseDataType innerType) {
final ClickHouseBitmap b;
if (bitmap instanceof RoaringBitmap) {
b = new ClickHouseRoaringBitmap((RoaringBitmap) bitmap, innerType);
} else if (bitmap instanceof MutableRoaringBitmap) {
b = new ClickHouseMutableRoaringBitmap((MutableRoaringBitmap) bitmap, innerType);
} else if (bitmap instanceof ImmutableRoaringBitmap) {
b = new ClickHouseImmutableRoaringBitmap((ImmutableRoaringBitmap) bitmap, innerType);
} else if (bitmap instanceof Roaring64Bitmap) {
b = new ClickHouseRoaring64NavigableMap(
Roaring64NavigableMap.bitmapOf(((Roaring64Bitmap) bitmap).toArray()), innerType);
} else if (bitmap instanceof Roaring64NavigableMap) {
b = new ClickHouseRoaring64NavigableMap((Roaring64NavigableMap) bitmap, innerType);
} else {
throw new IllegalArgumentException("Only RoaringBitmap is supported but got: " + bitmap);
}
return b;
}
@Override
public long serializedSizeInBytesAsLong() {
// no idea why it's implemented this way...
// https://github.com/RoaringBitmap/RoaringBitmap/blob/fd54c0a100629bb578946e2a0bf8b62784878fa8/RoaringBitmap/src/main/java/org/roaringbitmap/longlong/Roaring64NavigableMap.java#L1371-L1380
// TODO completely drop RoaringBitmap dependency
if (Roaring64NavigableMap.SERIALIZATION_MODE != Roaring64NavigableMap.SERIALIZATION_MODE_PORTABLE) {
throw new IllegalStateException(
"Please change Roaring64NavigableMap.SERIALIZATION_MODE to portable first");
}
return rb.serializedSizeInBytes();
}
如果不重视这些问题就会导致这些报错,这里贴出来以方便其他人可以搜索到本文
报错1:
java.lang.NoSuchFieldError: SERIALIZATION_MODE
报错2:
java.lang.IllegalStateException: Please change Roaring64NavigableMap.SERIALIZATION_MODE to portable first
at com.clickhouse.data.value.ClickHouseBitmap$ClickHouseRoaring64NavigableMap.serializedSizeInBytesAsLong(ClickHouseBitmap.java:199)
at com.clickhouse.data.value.ClickHouseBitmap.toByteBuffer(ClickHouseBitmap.java:591)
at com.clickhouse.data.value.ClickHouseBitmap.toBytes(ClickHouseBitmap.java:605)
at com.clickhouse.data.format.ClickHouseRowBinaryProcessor$BitmapSerDe.serialize(ClickHouseRowBinaryProcessor.java:50)
at com.clickhouse.data.ClickHouseDataProcessor.write(ClickHouseDataProcessor.java:593)
at com.clickhouse.jdbc.internal.InputBasedPreparedStatement.addBatch(InputBasedPreparedStatement.java:345)
解决方法:
spark任务提交前使用--jars 指定jar包,然后配置优先使用用户jar包
--jars /path/RoaringBitmap-1.2.1.jar \
--conf spark.driver.userClassPathFirst=true \
--conf spark.executor.userClassPathFirst=true \
在同步数据前,修改SERIALIZATION_MODE的值
Roaring64NavigableMap.SERIALIZATION_MODE=Roaring64NavigableMap.SERIALIZATION_MODE_PORTABLE