由於工作的需求,後續筆者工作需要和開源的OLAP資料庫ClickHouse打交道。ClickHouse是Yandex在2016年6月15日開源了一個分析型資料庫,以強悍的單機處理能力被稱道。
筆者在實際測試ClickHouse和閱讀ClickHouse的原始碼過程之中,對"戰鬥民族"開發的資料庫十分欣賞。ClickHouse不僅是一個很好的資料庫學習材料,而且同時應用了大量的CPP17的新特性進行開發,也是一個大型的Modern CPP的教導資料。
筆者接下來會陸續將閱讀ClickHouse的部分心得體會與通過原始碼閱讀筆記的方式和大家分享,坦白說,這種原始碼閱讀筆記很難寫啊。(多一分繁瑣,少一分就模糊了~~)
第一篇文章,我們就從聚合函式的實現開始聊起~~ 上車!
1.基礎知識的梳理
什麼是聚合函式?
聚合函式: 顧名思義就是對一組資料執行聚合計算並返回結果的函式。
這類函式在資料庫之中很常見,如:count, max, min, sum
等等。
ClickHouse的實現介面
- IAggregateFunction介面
在ClickHouse之中,定義了一個統一的聚合函式介面:IAggregateFunction.(在ClickHouse之中,所有的介面類都是以大寫的I開頭的。) 上文筆者提到的聚合函式,則都是作為抽象類IAggregateFunction的子類實現的。其中該介面最為核心的方法是下面這5個方法:- add函式:最為核心的呼叫介面,將對應AggregateDataPtr指標之中資料取出,與列columns中的第row_num的資料進行對應的聚合計算。(這裡可以看到ClickHouse是一個純粹的列式儲存資料庫,所有的操作都是基於列的資料結構。)
- merge函式:將兩個聚合結果進行合併的函式,通常用在併發執行聚合函式的過程之中,需要將對應的聚合結果進行合併。
- serialize函式與deserialize函式:序列化與反序列化的函式,通常用於spill to disk或分散式場景需要儲存或傳輸中間結果的。
- addBatch函式:這是函式也是非常重要的,雖然它僅僅實現了一個for迴圈呼叫add函式。它通過這樣的方式來減少虛擬函式的呼叫次數,並且增加了編譯器內聯的概率。(虛擬函式的呼叫需要一次訪存指令,一次查表,最終才能定位到需要呼叫的函式上,這在傳統的火山模型的實現上會帶來極大的CPU開銷。)
/** Adds a value into aggregation data on which place points to.
* columns points to columns containing arguments of aggregation function.
* row_num is number of row which should be added.
* Additional parameter arena should be used instead of standard memory allocator if the addition requires memory allocation.
*/
virtual void add(AggregateDataPtr place, const IColumn ** columns, size_t row_num, Arena * arena) const = 0;
/// Merges state (on which place points to) with other state of current aggregation function.
virtual void merge(AggregateDataPtr place, ConstAggregateDataPtr rhs, Arena * arena) const = 0;
/// Serializes state (to transmit it over the network, for example).
virtual void serialize(ConstAggregateDataPtr place, WriteBuffer & buf) const = 0;
/// Deserializes state. This function is called only for empty (just created) states.
virtual void deserialize(AggregateDataPtr place, ReadBuffer & buf, Arena * arena) const = 0;
// /** Contains a loop with calls to "add" function. You can collect arguments into array "places"
* and do a single call to "addBatch" for devirtualization and inlining.
*/
virtual void addBatch(size_t batch_size, AggregateDataPtr * places, size_t place_offset, const IColumn ** columns, Arena * arena) const = 0;
- 抽象類IColumn
上面的介面IAggregateFunction的函式使用到了ClickHouse的核心介面IColumn類,這裡也進行簡要的介紹。 IColumn 介面表達了所有資料在ClickHouse之中的用記憶體表達的資料結構,其他帶有具體資料型別的如ColumnUInt8、ColumnArray
等, 都實現了對應的列介面,並且在子類之中具象實現了不同的記憶體佈局。
IColumn的子類實現細節很瑣碎,筆者這裡就暫時不展開講了,筆者這裡就簡單講講涉及到聚合函式呼叫部分的IColumn介面的對應方法:
這裡columns
是一個二維陣列,通過columns[0]
可以取到第一列。(這裡只有涉及到一列,為什麼columns是二維陣列呢?因為處理array等列的時候,也是通過對應的介面,而array就需要應用二維陣列了. )
注意這裡有一個強制的型別轉換,column已經轉換為ColVecType型別了,這是模板派生出IColumn的子類。
然後通過IColumn
子類實現的getData方法
獲取對應row_num
行的資料進行add函式呼叫就完成了一次聚合函式的計算了。
void add(AggregateDataPtr place, const IColumn ** columns, size_t row_num, Arena *) const override
{
const auto & column = static_cast<const ColVecType &>(*columns[0]);
this->data(place).add(column.getData()[row_num]);
}
- IAggregateFunctionHelper介面
這個介面是上面提到 IAggregateFunction的輔助子類介面,它很巧妙的通過模板的型別派生,將虛擬函式的呼叫轉換為函式指標的呼叫,這個在實際聚合函式的實現過程之中能夠大大提高計算的效率。
函式addFree
就實現了我上述所說的過程,但是它是一個private的函式,所以通常我們都是通過getAddressOfAddFunction
獲取對應的函式地址。這在聚合查詢的過程之中能夠提高20%左右的執行效率。
template <typename Derived>
class IAggregateFunctionHelper : public IAggregateFunction
{
private:
static void addFree(const IAggregateFunction * that, AggregateDataPtr place, const IColumn ** columns, size_t row_num, Arena * arena)
{
static_cast<const Derived &>(*that).add(place, columns, row_num, arena);
}
public:
IAggregateFunctionHelper(const DataTypes & argument_types_, const Array & parameters_)
: IAggregateFunction(argument_types_, parameters_) {}
AddFunc getAddressOfAddFunction() const override { return &addFree; }
- AggregateFunctionFactory類
顧名思義,這個是一個生成聚合函式的工廠類。它的邏輯很簡單,所有ClickHouse之中所相關的聚合函式都是通過這個工廠類註冊並且獲取,然後進行呼叫的。
class AggregateFunctionFactory final : private boost::noncopyable, public IFactoryWithAliases<AggregateFunctionCreator>
{
public:
static AggregateFunctionFactory & instance();
/// Register a function by its name.
/// No locking, you must register all functions before usage of get.
void registerFunction(
const String & name,
Creator creator,
CaseSensitiveness case_sensitiveness = CaseSensitive);
/// Throws an exception if not found.
AggregateFunctionPtr get(
const String & name,
const DataTypes & argument_types,
const Array & parameters = {},
int recursion_level = 0) const;
2.聚合函式的註冊流程
有了上述的背景知識,我們接下來舉個栗子。來看看一個聚合函式的實現細節,以及它是如何被使用的。
AggregateFunctionSum
筆者這裡選取了一個很簡單的聚合運算元Sum,我們來看看它實現的程式碼細節。
這裡我們可以看到AggregateFunctionSum
是個final類,無法被繼承了。而它繼承了上面提到的IAggregateFunctionHelp
類的子類IAggregateFunctionDataHelper
類。
這裡我們就重點看,這個類override了getName
方法,返回了對應的名字sum。並且實現了我們上文提到的四個核心的方法。
- add
- merge
- seriable
- deserialize
template <typename T, typename TResult, typename Data>
class AggregateFunctionSum final : public IAggregateFunctionDataHelper<Data, AggregateFunctionSum<T, TResult, Data>>
{
public:
using ResultDataType = std::conditional_t<IsDecimalNumber<T>, DataTypeDecimal<TResult>, DataTypeNumber<TResult>>;
using ColVecType = std::conditional_t<IsDecimalNumber<T>, ColumnDecimal<T>, ColumnVector<T>>;
using ColVecResult = std::conditional_t<IsDecimalNumber<T>, ColumnDecimal<TResult>, ColumnVector<TResult>>;
String getName() const override { return "sum"; }
AggregateFunctionSum(const DataTypes & argument_types_)
: IAggregateFunctionDataHelper<Data, AggregateFunctionSum<T, TResult, Data>>(argument_types_, {})
, scale(0)
{}
AggregateFunctionSum(const IDataType & data_type, const DataTypes & argument_types_)
: IAggregateFunctionDataHelper<Data, AggregateFunctionSum<T, TResult, Data>>(argument_types_, {})
, scale(getDecimalScale(data_type))
{}
DataTypePtr getReturnType() const override
{
if constexpr (IsDecimalNumber<T>)
return std::make_shared<ResultDataType>(ResultDataType::maxPrecision(), scale);
else
return std::make_shared<ResultDataType>();
}
void add(AggregateDataPtr place, const IColumn ** columns, size_t row_num, Arena *) const override
{
const auto & column = static_cast<const ColVecType &>(*columns[0]);
this->data(place).add(column.getData()[row_num]);
}
void merge(AggregateDataPtr place, ConstAggregateDataPtr rhs, Arena *) const override
{
this->data(place).merge(this->data(rhs));
}
void serialize(ConstAggregateDataPtr place, WriteBuffer & buf) const override
{
this->data(place).write(buf);
}
void deserialize(AggregateDataPtr place, ReadBuffer & buf, Arena *) const override
{
this->data(place).read(buf);
}
void insertResultInto(ConstAggregateDataPtr place, IColumn & to) const override
{
auto & column = static_cast<ColVecResult &>(to);
column.getData().push_back(this->data(place).get());
}
private:
UInt32 scale;
};
接下來,ClickHouse實現了兩種聚合計算:AggregateFunctionSumData
和AggregateFunctionSumKahanData
。後者是用Kahan演算法避免float型別精度損失的,我們可以暫時不細看。直接看SumData的實現。這是個模板類,之前我們講到AggregateFunction
的函式就是通過AggregateDataPtr
指標來獲取AggregateFunctionSumData
的地址,來呼叫add實現聚合運算元的。我們可以看到AggregateFunctionSumData實現了前文提到的add, merge, write,read
四大方法,正好和介面一一對應上了。
template <typename T>
struct AggregateFunctionSumData
{
T sum{};
void add(T value)
{
sum += value;
}
void merge(const AggregateFunctionSumData & rhs)
{
sum += rhs.sum;
}
void write(WriteBuffer & buf) const
{
writeBinary(sum, buf);
}
void read(ReadBuffer & buf)
{
readBinary(sum, buf);
}
T get() const
{
return sum;
}
};
ClickHouse在Server啟動時。main函式之中會呼叫registerAggregateFunction
的初始化函式註冊所有的聚合函式。
然後呼叫到下面的函式:
void registerAggregateFunctionSum(AggregateFunctionFactory & factory)
{
factory.registerFunction("sum", createAggregateFunctionSum<AggregateFunctionSumSimple>, AggregateFunctionFactory::CaseInsensitive);
factory.registerFunction("sumWithOverflow", createAggregateFunctionSum<AggregateFunctionSumWithOverflow>);
factory.registerFunction("sumKahan", createAggregateFunctionSum<AggregateFunctionSumKahan>);
}
這裡又呼叫了 factory.registerFunction("sum", createAggregateFunctionSum<AggregateFunctionSumSimple>, AggregateFunctionFactory::CaseInsensitive);
來進行上述我們看到的聚合函式的註冊。這裡有一點很噁心的模板程式碼,筆者這裡簡化了一下,把註冊的部分函式拉出來:
createAggregateFunctionSum(const std::string & name, const DataTypes & argument_types, const Array & parameters)
{
AggregateFunctionPtr res;
DataTypePtr data_type = argument_types[0];
if (isDecimal(data_type))
res.reset(createWithDecimalType<Function>(*data_type, *data_type, argument_types));
else
res.reset(createWithNumericType<Function>(*data_type, argument_types));
return res;
這裡的Function
模板就是上面的AggregateFunctionSumSimple
, 而它又是下面的模板型別:
template <typename T> using AggregateFunctionSumSimple = typename SumSimple<T>::Function;
template <typename T>
struct SumSimple
{
/// @note It uses slow Decimal128 (cause we need such a variant). sumWithOverflow is faster for Decimal32/64
using ResultType = std::conditional_t<IsDecimalNumber<T>, Decimal128, NearestFieldType<T>>;
using AggregateDataType = AggregateFunctionSumData<ResultType>;
using Function = AggregateFunctionSum<T, ResultType, AggregateDataType>;
};
不知道讀者被繞暈了沒,最終繞回來還是new出來這個AggregateFunctionSum<T, ResultType, AggregateDataType>
也就是完成了這個求和運算元的註冊,後續我們get出來就可以愉快的呼叫啦。(這裡這部分的模板變化比較複雜,如果看不明白可以回到原始碼梳理一下~~~)
3. 小結
好了,關於聚合函式的基礎資訊,和它是如何實現並且通過工廠方法註冊獲取的流程算是搞明白了。
關於其他的聚合運算元,也是大同小異的方式。筆者就不再贅述了,感興趣的可以回到原始碼之中繼續一探究竟。講完了聚合函式的實現,下一篇筆者就要繼續給探究聚合函式究竟在ClickHouse之中是如何和列存結合使用,並實現向量化的~~。
筆者是一個ClickHouse的初學者,對ClickHouse有興趣的同學,也歡迎和筆者多多指教,交流。