如果只站在 SQL 语义层,DECIMAL(P,S) 看起来几乎没有争议。它有固定精度、有固定小数位,既能做算术,也能参与排序和持久化。可一旦真的翻开数据库内核源码,就会发现 MySQL 和 ClickHouse 对 Decimal 的理解根本不是一回事。
MySQL 写出来的,是一套十进制大数库,再加一套适合行存比较与排序的字段编码。ClickHouse 写出来的,则是一套"带 scale 的定长整数"类型系统,再配一条围绕列式执行器展开的算术路径。
同样是 Decimal,为什么最后会长成两套世界?我想从源码里把这件事讲清楚。
一、先看结论:两边的工程目标与实现约束不同
如果把 Decimal 的实现放回各自引擎所服务的工作负载里看,差异就会更清楚。MySQL 更典型地面向 TP 场景:单行读写密集、索引比较频繁、字段值要直接进入排序与比较链路;ClickHouse 更典型地面向 AP 场景:数据规模更大、列式扫描更重、批量计算与向量化吞吐优先。因此,虽然两边都提供 Decimal 类型,但它们首先面对的是两套不同的工程目标和实现约束,值表示、落盘编码以及 arithmetic(+ - * / %) 的执行路径,也就必然沿着不同方向演化。
MySQL 更关心这些问题:
- 字段在存储层如何比较
- 排序和索引能不能直接复用编码结果
- Decimal 在表达式求值时是否能像一个真正的十进制对象那样存在
ClickHouse 更关心的则是:
- Decimal 列能不能像整数列一样连续排布
- 算术路径能不能尽量落在定长整数上
- 批量序列化与向量化执行能不能简单直接
因此,两边的差异不是"语法上谁支持得更多",而是TP 与 AP、行存与列存、数据规模与执行方式共同塑造出来的工程差异。
二、Decimal 值表示:一个是一套十进制大数库,一个是"缩放后的整数"
MySQL:Decimal 更像一套十进制大数库
MySQL 的起点在 include/decimal.h。这里定义的 decimal_t 非常说明问题:
cpp
struct decimal_t {
int intg, frac, len;
bool sign;
decimal_digit_t *buf;
};
这里的关键不是 buf,而是 intg 和 frac。在 MySQL 里,一个 Decimal 值对象自己就知道:
- 整数部分有多少十进制位
- 小数部分有多少十进制位
- 自己的符号是什么
这意味着小数点位置是值对象内部属性,而不是外部元数据。
到了 sql/my_decimal.h,MySQL 又把它包成了 my_decimal:
cpp
class my_decimal : public decimal_t {
decimal_digit_t buffer[DECIMAL_BUFF_LENGTH];
};
这层包装的意义很务实:给 SQL 层表达式求值一个自带 buffer 的 Decimal 对象,避免频繁分配。更关键的是,它继承的底层语义没有变,仍然是一套十进制大数表示。
在 strings/decimal.cc 里,MySQL 使用的是 base 10^9 的大 digit 体系,也就是每个 decimal_digit_t 不代表 1 位十进制数字,而是代表 9 位十进制数字。这个选择让它在实现大数加减乘除时,不至于退回逐字符计算。
所以如果一句话概括 MySQL 的 Decimal:
它先把 Decimal 当成"十进制对象"认真建模,然后才考虑它如何参与字段存储和 SQL 表达式。
ClickHouse:Decimal 更像"缩放后的整数"
ClickHouse 的起点完全不同。base/base/Decimal.h 里,真正的核心几乎只有这一行:
cpp
template <typename T>
struct Decimal {
T value;
};
源码注释甚至直接说:
cpp
/// It is only a "storage" for decimal.
/// To perform operations, you also have to provide a scale.
这句话把实现哲学说得很透:在 ClickHouse 里,Decimal 值对象本身并不完整。它只是一个缩放后的整数,要真正解释它的语义,还必须把 scale 一起带上。
例如:
123.45在 scale=2 时,本体可能就是12345123.4500在 scale=4 时,本体可能就是1234500
这两个值在 SQL 世界里只是小数位多少不同,但在 ClickHouse 内核世界里,首先是两个不同 scale 的整数。
再看 src/Columns/ColumnDecimal.h:
cpp
using Container = PaddedPODArray<T>;
...
Container data;
UInt32 scale;
这说明 ClickHouse 的 Decimal 列本质上是一段连续的定长数组,外加一个列级 scale。值体尽量轻,解释交给类型和列元数据。
所以如果一句话概括 ClickHouse 的 Decimal:
它不是先实现一套十进制大数对象,而是先把 Decimal 组织成"定长整数值体 + scale 元数据"这套更适合列式执行的形态。
三、同样要落盘,一个要编码,一个直接写 value
真正能把两边差异一下子拉开的,是存储路径。
MySQL:字段存储是一种"可排序编码"
strings/decimal.cc 对 decimal2bin() 的说明非常重要:
cpp
binary representations of the same {precision, scale} can be compared
with memcmp - with the same result as decimal_cmp()
也就是说,MySQL 把 Decimal 转成二进制,不只是为了省空间,而是为了得到一种在同一 (precision, scale) 下可直接比较的编码。
这件事在 sql/field.cc 里有直接证据:
cpp
int Field_new_decimal::cmp(const uchar *a, const uchar *b) const {
return memcmp(a, b, bin_size);
}
字段比较直接走 memcmp(),说明落盘格式已经被设计成"排序友好"的形式。decimal2bin() 的注释还特别写到:
- 负数要逐字节取反
- 首字节的最高位还要再翻一次
这么做的目的只有一个:把十进制数值顺序映射成无符号字节序顺序。
这是一种非常典型的行存思维。字段不仅要被保存,还要被比较、被排序、被用于索引键。
ClickHouse:值本体就是最自然的存储单元
ClickHouse 的序列化路径则简单得多。SerializationDecimalBase.cpp 里几乎没有绕弯:
cpp
writeBinaryLittleEndian(x, ostr);
...
ostr.write(reinterpret_cast<const char *>(&x[offset]), sizeof(FieldType) * limit);
单值路径就是按 little-endian 写出底层 value;批量路径在满足端序条件时,可以直接把底层连续数组的内存区间写入输出流。
为什么可以这么简单?
因为 ClickHouse 的 Decimal 值体本来就是定长整数,列里又是连续数组。它不需要额外再做一层"为了比较而存在的字段编码",因为列式数据库的比较和排序并不是靠字段字节串原地 memcmp() 来完成的。
这一点正好展现出两边的存储哲学:
- MySQL:字段编码要服务比较和排序
- ClickHouse:列布局要服务顺序 IO 和批量执行
四、precision/scale 的传播,一个像公式,一个像类型系统
如果说值表示和存储路径决定了"Decimal 是什么",那么表达式结果类型推导决定了"Decimal 在 SQL 表达式求值里如何参与计算"。
MySQL:结果精度规则直接写成公式
sql/item_func.cc 里,加减法的 result_precision() 是这样的:
cpp
decimals = max(args[0]->decimals, args[1]->decimals);
int arg1_int = args[0]->decimal_precision() - args[0]->decimals;
int arg2_int = args[1]->decimal_precision() - args[1]->decimals;
int precision = max(arg1_int, arg2_int) + 1 + decimals;
这个规则非常接近人类手算:
- 小数位取两边较大者
- 整数位取两边较大者,再额外预留 1 位进位
- 最终总精度 = 整数位 + 小数位
例如 DECIMAL(10,2) + DECIMAL(8,4):
- 左边整数位是
8 - 右边整数位是
4 - 结果 scale =
4 - 结果 precision =
max(8,4)+1+4 = 13
乘法也同样直接:
cpp
decimals = min(args[0]->decimals + args[1]->decimals, DECIMAL_MAX_SCALE);
precision = min(args[0]->decimal_precision() + args[1]->decimal_precision(),
DECIMAL_MAX_PRECISION);
除法则不是"带一点特殊处理的乘法",而是单独定义了自己的 result_precision() 规则:
cpp
uint precision = min<uint>(
args[0]->decimal_precision() + args[1]->decimals + m_prec_increment,
DECIMAL_MAX_PRECISION);
decimals = min<uint>(args[0]->decimals + m_prec_increment, DECIMAL_MAX_SCALE);
这组公式需要拆开看:
- 结果
scale = min(S1 + m_prec_increment, DECIMAL_MAX_SCALE) - 结果
precision = min(P1 + S2 + m_prec_increment, DECIMAL_MAX_PRECISION)
也就是说,MySQL 在除法里会额外引入一个 m_prec_increment。这个值来自 div_precincrement(对外对应 session variable div_precision_increment,默认值是 4,可以通过 SET SESSION div_precision_increment = N 调整),本质上是在说:除法结果不能只靠输入 scale 机械推导,而要为商保留一段额外精度。
举个例子,假设:
- 左操作数:
DECIMAL(10,2) - 右操作数:
DECIMAL(8,4) m_prec_increment = 4
那么:
- 结果
scale = min(2 + 4, 30) = 6 - 结果
precision = min(10 + 4 + 4, 65) = 18
因此它更接近 DECIMAL(18,6) 的推导方向。
这说明 MySQL 对 Decimal 除法的态度不是"随便给个近似推导",而是把除法单独当作需要额外精度预算的运算来处理。
ClickHouse:先决定结果 scale,再落到结果桶
ClickHouse 的关键在 src/Core/DecimalFunctions.h:
cpp
if constexpr (is_multiply)
scale = tx.getScale() + ty.getScale();
else if constexpr (is_division)
scale = tx.getScale();
else
scale = max(tx.getScale(), ty.getScale());
也就是说:
+/-/%的结果 scale 取较大者*的结果 scale 直接相加/的结果 scale 默认保持左操作数的 scale
而 precision 并不是像 MySQL 那样一步步预算出来,而是交给结果类型桶去决定。DataTypeDecimalBase.h 明确写死了各类 Decimal 的最大精度:
Decimal32 -> 9Decimal64 -> 18Decimal128 -> 38Decimal256 -> 76
这意味着 ClickHouse 的推导风格更像这样:
- 先决定结果应该保留多少 scale
- 再决定这个结果应该落在哪个 Decimal 宽度桶里
区别在于:MySQL 会按操作数的 (P,S) 用公式算出一个尽量"贴合输入"的结果 (P,S);而 ClickHouse 往往先按规则确定目标 scale,再把 precision 的上界交给底层 Decimal 宽度桶(Decimal32/64/128/256)来承载。
换句话说,ClickHouse 的这段逻辑更像"为表达式结果选择一个合适的底层数值类型(以及对应的 scale)",而不是"把结果精度精确预算到一个具体的 (P,S)"。
这条路径在源码里可以继续展开成 decimalResultType() 和 binaryOpResult()。前者负责把结果 trait 组装成具体的 Decimal 类型,后者负责真正决定结果 scale 和结果桶:
cpp
template <bool is_multiply, bool is_division, typename T, typename U, template <typename> typename DecimalType>
inline auto decimalResultType(const DecimalType<T> & tx, const DecimalType<U> & ty)
{
const auto result_trait = DecimalUtils::binaryOpResult<is_multiply, is_division>(tx, ty);
return DecimalType<typename decltype(result_trait)::FieldType>(result_trait.precision, result_trait.scale);
}
template <bool is_multiply, bool is_division, typename T, typename U, template <typename> typename DecimalType>
inline auto binaryOpResult(const DecimalType<T> & tx, const DecimalType<U> & ty)
{
UInt32 scale{};
if constexpr (is_multiply)
scale = tx.getScale() + ty.getScale();
else if constexpr (is_division)
scale = tx.getScale();
else
scale = (tx.getScale() > ty.getScale() ? tx.getScale() : ty.getScale());
if constexpr (sizeof(T) < sizeof(U))
return DataTypeDecimalTrait<U>(DecimalUtils::max_precision<U>, scale);
else
return DataTypeDecimalTrait<T>(DecimalUtils::max_precision<T>, scale);
}
结果类型确定之后,执行层还会继续算输入对齐倍率,也就是 scale_a / scale_b。这一步并不改变结果类型,而是反推左右输入各自要乘多少 10^Δ 才能对齐到目标 scale:
cpp
const ResultDataType type = decimalResultType<is_multiply, is_division>(left, right);
const ResultType scale_a = [&]
{
if constexpr (IsDataTypeDecimal<RightDataType> && is_division)
return right.getScaleMultiplier(); // division impl uses only scale_a
else
return type.scaleFactorFor(left, false);
}();
const ResultType scale_b = [&]
{
return type.scaleFactorFor(right, is_division);
}();
五、arithmetic(+ - * / %) 的实现路径,一个默认在十进制大数域里算,一个默认在底层定长整数算术域里算
MySQL:默认就在十进制大数域里做算术
MySQL 的乘法注释非常典型:
cpp
there's no need in fast multiplication algorithms,
7-digit numbers can be multiplied with a naive O(n*n) method.
也就是说,decimal_mul() 明确采用的是朴素多精度乘法。注意,它不是"把值转成整数后做普通乘法",而是在 base 10^9 的 digit group 上做大数乘法。
除法更直接,源码注释写明:
cpp
naive division algorithm (Knuth's Algorithm D in 4.3.1)
这几乎已经是"教科书实现说明书"了。do_div_mod() 不是一个随手拼出来的除法器,而是明确以 Knuth Algorithm D 为基础的多精度除法实现。
模运算则共享这条除法路径,保留 remainder。也就是说,在 MySQL 里,% 本质上仍然是在十进制大数框架里完成的。
先插一段:MySQL arithmetic(+ - * / %) 的内核视角概览
回顾前面 decimal_t / my_decimal 的计算态结构,最关键的成员其实是 buf。后续的加减乘除并不是围绕"一个整体整数值"展开,而是围绕 buf[] 这段 dec1 数组展开,其中每个 dec1 保存一组 base 10^9 的十进制数字。
decimal_t / my_decimal 在计算态里关心的是:
intg:整数位位数frac:小数位位数sign:符号buf[]:按 base10^9切分后的 digit groups
对于 +/-,MySQL 并不是把整个数先转成某个 128 位整数去算,而是直接在这些 digit groups 上做逐组加减。do_add() 的实现分成了三段:
- 先复制较长小数尾部
- 再对公共区间逐组相加
- 最后再把较长整数前缀和进位处理完
do_sub() 则先决定谁大谁小,必要时交换两个输入,再逐组借位减法。这也是为什么 decimal_add() / decimal_sub() 本体都只是非常薄的分发:
cpp
int decimal_add(const decimal_t *from1, const decimal_t *from2, decimal_t *to) {
if (likely(from1->sign == from2->sign)) return do_add(from1, from2, to);
return do_sub(from1, from2, to);
}
也就是说,decimal_add() / decimal_sub() 外层只负责按符号分发,真正的工作都落在后面的几个核心计算过程里。
下面按函数看核心计算流程:
text
do_add():同号加法,按 dec1 数组逐组相加并处理进位
1. 计算整数/小数部分需要的 dec1 组数
2. 判断最高位是否可能产生额外进位(carry)
3. 先复制较长的小数尾部,因为这部分没有对应项,只需要原样带到结果里
4. 对公共区间逐组相加
5. 把进位(carry)继续向更高整数组传播
6. 回填结果的符号、整数位和小数位
do_sub():减法主流程,先比较大小,再逐组借位相减
1. 去掉前导 0,比较两个输入的绝对值
2. 必要时交换输入,并翻转结果符号
3. 处理较长的小数尾部
4. 对公共区间逐组相减
5. 把借位(borrow)向更高整数组传播
6. 回填结果的符号、整数位和小数位
decimal_mul():乘法主流程,按 dec1 组做朴素乘累加
1. 去掉输入高位的前导 0
2. 计算结果的整数位、小数位和符号
3. 清空结果缓冲区
4. 双重循环:按 dec1 组做乘累加
5. 处理进位(carry)、溢出(overflow)、负零和高位前导 0
do_div_mod():除法/取模共享主流程,按同一套多精度除法框架求商或余数
1. 去掉除数和被除数前导 0
2. 估算结果的整数位和小数位
3. 做规范化:先把除数最高位放大到更容易估计商的范围,
其中 `norm_factor` 是放大倍率,`norm2` 是放大后的除数首组
4. 猜商(guess)
5. 用猜商去乘除数并回减
6. 必要时修正猜商(guess)
7. 输出商(quotient)或余数(remainder)
后面我会再单独写一篇文章,更清晰地梳理 do_add/do_sub、decimal_mul、do_div_mod 这些核心算法流程。
ClickHouse:默认先做 scale 对齐,再做底层定长整数算术
结果类型和 scale_a / scale_b 确定之后,真正的执行就进入 DecimalBinaryOperation::process()。这一层不再关心"结果该是什么类型",只关心两件事:先怎么对齐,再怎么把整列结果写出来。
cpp
template <OpCase op_case, bool is_decimal_a, bool is_decimal_b>
static void process(const auto & a, const auto & b, ResultContainerType & c,
NativeResultType scale_a, NativeResultType scale_b, const NullMap * right_nullmap = nullptr)
{
size_t size;
if constexpr (op_case == OpCase::LeftConstant)
size = b.size();
else
size = a.size();
if constexpr (is_plus_minus_compare)
{
if (scale_a != 1)
{
for (size_t i = 0; i < size; ++i)
c[i] = applyScaled<true>(
static_cast<NativeResultType>(unwrap<op_case, OpCase::LeftConstant>(a, i)),
static_cast<NativeResultType>(unwrap<op_case, OpCase::RightConstant>(b, i)),
scale_a);
return;
}
if (scale_b != 1)
{
for (size_t i = 0; i < size; ++i)
c[i] = applyScaled<false>(
static_cast<NativeResultType>(unwrap<op_case, OpCase::LeftConstant>(a, i)),
static_cast<NativeResultType>(unwrap<op_case, OpCase::RightConstant>(b, i)),
scale_b);
return;
}
}
else if constexpr (is_multiply)
{
if (scale_a != 1)
{
for (size_t i = 0; i < size; ++i)
c[i] = applyScaled<true, false>(
static_cast<NativeResultType>(unwrap<op_case, OpCase::LeftConstant>(a, i)),
static_cast<NativeResultType>(unwrap<op_case, OpCase::RightConstant>(b, i)),
scale_a);
return;
}
}
else if constexpr (is_division && is_decimal_b)
{
processWithRightNullmapImpl<op_case>(a, b, c, size, right_nullmap, [&scale_a](const auto & left, const auto & right)
{
return applyScaledDiv<is_decimal_a>(
static_cast<NativeResultType>(left), right, scale_a);
});
return;
}
}
这段 process() 里,+/- 会优先尝试 applyScaled<true/false>(),* 进入乘法分支,/ 进入 applyScaledDiv(),而 % 没有单独的对齐分支,最后会和普通 fallback 路径一样落到单元素算子上。
因此,这一层最值得看的其实是三个单元入口:
applyScaled():给+/-和乘法用,先把一侧放大到目标 scaleapplyScaledDiv():给除法用,先把被除数放大到目标 scaleOp::apply():真正执行底层定长整数算术,%也会落到这里
先看 applyScaled():
cpp
template <bool scale_left, bool may_check_overflow = true>
static NativeResultType applyScaled(NativeResultType a, NativeResultType b, NativeResultType scale)
{
if constexpr (scale_left)
a *= scale;
else
b *= scale;
return Op::template apply<NativeResultType>(a, b);
}
除法单独走 applyScaledDiv():
cpp
template <bool is_decimal_a>
static NativeResultType applyScaledDiv(NativeResultType a, NativeResultType b, NativeResultType scale)
{
if constexpr (!is_decimal_a)
scale *= scale;
a *= scale;
return Op::template apply<NativeResultType>(a, b);
}
批处理循环的最内层,最终都会落到具体运算符自己的 apply()。以加法、乘法、取模为例,底层单元素函数分别是:
cpp
template <typename A, typename B>
struct PlusImpl
{
using ResultType = typename NumberTraits::ResultOfAdditionMultiplication<A, B>::Type;
static ResultType apply(A a, B b)
{
return static_cast<ResultType>(a) + static_cast<ResultType>(b);
}
static bool apply(A a, B b, ResultType & c)
{
return common::addOverflow(static_cast<ResultType>(a), b, c);
}
};
template <typename A, typename B>
struct MultiplyImpl
{
using ResultType = typename NumberTraits::ResultOfAdditionMultiplication<A, B>::Type;
template <typename Result = ResultType>
static NO_SANITIZE_UNDEFINED Result apply(A a, B b)
{
return static_cast<Result>(a) * static_cast<Result>(b);
}
};
template <typename A, typename B>
struct ModuloImpl
{
using ResultType = typename NumberTraits::ResultOfModulo<A, B>::Type;
using IntegerAType = typename NumberTraits::ToInteger<A>::Type;
using IntegerBType = typename NumberTraits::ToInteger<B>::Type;
template <typename Result = ResultType>
static Result apply(A a, B b)
{
if constexpr (is_floating_point<ResultType>)
return static_cast<ResultType>(a) - trunc(static_cast<ResultType>(a) / static_cast<ResultType>(b)) * static_cast<ResultType>(b);
else
return IntegerAType(a) % IntegerBType(b);
}
};
所以 ClickHouse 这一条默认路径可以压成一句话:在 process() 的列式循环里,按目标 scale 调用 applyScaled() / applyScaledDiv() / Op::apply(),完成底层定长整数算术。
六、两个例子,把"设计取舍"看得更具体一点
例子 1:DECIMAL(10,2) + DECIMAL(8,4)
在 MySQL 里,这是一道标准的精度预算题:
- scale =
4 - 整数位 =
max(8,4)+1 = 9 - precision =
13
在 ClickHouse 里,这更像一条类型和执行规则:
- 目标 scale =
4 - 低 scale 一侧先乘
10^(4-2) - 然后执行整数加法
两边得到的 SQL 语义相近,但实现世界完全不同。MySQL 在推导"结果应该有几位",ClickHouse 在推导"结果应该按什么 scale 解释,并如何把输入统一到这个尺度上"。
例子 2:DECIMAL(18,6) * DECIMAL(18,6)
MySQL 的思路是:
- 结果 scale =
12 - precision =
18 + 18,再裁到上限 - 执行走
decimal_mul()的多精度乘法
ClickHouse 的思路是:
- 结果 scale =
12 - 结果先落到某个 Decimal 宽度桶
- 执行时尽量走整数乘法,并做 overflow 检查
- 如果你显式要求更高精度,再切换到 digit arithmetic
这两个例子背后的差别,其实就是本文最重要的结论:
MySQL 优化的是"Decimal 作为十进制数"的实现完整性,ClickHouse 优化的是"Decimal 作为列式数值类型"的执行路径。
七、总结
| 维度 | MySQL | ClickHouse |
|---|---|---|
| 值表示 | decimal_t / my_decimal,自带 intg/frac/sign |
Decimal<T>{T value;},值体只存缩放整数 |
| 计算模型 | 十进制大数 | scale 对齐后的整数 |
| 存储模型 | decimal2bin() 定长编码,可 memcmp() |
little-endian 直接写 value |
| 结果 scale | 显式公式 | 固定规则:+/-/% 取较大者,* 相加,/ 取左操作数 scale |
| precision 处理 | 重新预算 | 落到结果 Decimal 桶 |
| 默认乘法 | 朴素多精度乘法 | 整数乘法 |
| 默认除法 | Knuth Algorithm D | 缩放整数除法 |
| 工程目标 | 行存字段语义、比较、索引友好 | 列式执行、连续内存、批量处理 |
结尾
如果只从 SQL 语法表面去看,MySQL 和 ClickHouse 都实现了 DECIMAL(P,S)。但如果从源码往里走,会发现 MySQL 更重点解决的是"如何把 Decimal 作为一个十进制数稳定地表示、计算,并进一步服务字段比较、排序和存储",而 ClickHouse 更重点解决的是"如何让 Decimal 在列式执行里以连续内存、批量计算和向量化友好的方式高效运行"。
所以更直白地说,MySQL 的 Decimal,本质上是一套十进制大数实现;ClickHouse 的 Decimal,本质上是"整数值 + scale 解释"的实现。