同样是 Decimal,为什么 MySQL 和 ClickHouse 会写成两套世界

如果只站在 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 时,本体可能就是 12345
  • 123.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 -> 9
  • Decimal64 -> 18
  • Decimal128 -> 38
  • Decimal256 -> 76

这意味着 ClickHouse 的推导风格更像这样:

  1. 先决定结果应该保留多少 scale
  2. 再决定这个结果应该落在哪个 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[]:按 base 10^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():给 +/- 和乘法用,先把一侧放大到目标 scale
  • applyScaledDiv():给除法用,先把被除数放大到目标 scale
  • Op::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 解释"的实现。

相关推荐
leo在掘金44 分钟前
一天155万亿Token,DeepInfer省在哪
后端·架构
阿良别做梦1 小时前
LangGraph 学习之路:让工作流返工,也让它及时停下
后端·开源
我的div丢了肿么办1 小时前
go语言抽离路由文件以及对路由进行分组
后端·go
zz在掘金337081 小时前
Django __init__.py 高级应用速记
后端
软件工程师文艺6661 小时前
Teamcenter FMS 文件管理系统深度解析:缓存策略、SSL 部署与性能调优
后端
Gopher_HBo1 小时前
类型系统 —— Type、rtype 与运行时类型元数据
后端
今天早睡1 小时前
并发分支、同步汇总与 State 合并
后端·架构
王川7081 小时前
商协会后台前端的模块聚合实践
前端·后端
旋律4141 小时前
String、StringBuilder、StringBuffer:三个"字符串",到底区别在哪?
java·后端