当前位置: 代码网 > it编程>编程语言>Asp.net > 如何在 C# 类型系统上实现一个 SQL 查询引擎

如何在 C# 类型系统上实现一个 SQL 查询引擎

2026年03月03日 Asp.net 我要评论
前言在 .net 里写查询的时候,很多场景下数据其实早就都在内存里了:不是数据库连接,也不是某个远程服务的结果,而就是一个数组或者list<t>。我只是想过滤一下、投影一下。这时候,通常有

前言

在 .net 里写查询的时候,很多场景下数据其实早就都在内存里了:不是数据库连接,也不是某个远程服务的结果,而就是一个数组或者 list<t>。我只是想过滤一下、投影一下。这时候,通常有几种选择:

  • 写一个 foreach 循环 —— 性能好、可控,但代码稍微有点啰嗦;
  • 用 linq —— 写起来舒服,看起来也优雅,就是有迭代器、委托带来的那点开销;
  • 要么干脆极端一点:把数据塞进数据库,再写真正的 sql(这听起来就有点反直觉……)

但是我想尝试一条完全不同的思路:如果我们把 c# 的类型系统本身,当成查询计划会怎样?

也就是说,不是像平时那样:

  • 在运行时构建一棵表达式树,
  • 再拿着这棵树去解释执行整个查询;

而是:写一段 sql 风格的字符串,把它编译成一个类型,这个类型从头到尾描述了整个查询管道,然后所有实际运行时的逻辑都走静态方法。

这个想法最终促成了 typedsql —— 一个用 c# 类型系统实现的内存内 sql 查询引擎。

把查询变成嵌套的泛型类型

typedsql 的核心想法看上去非常简单:一个查询,其实可以是一串嵌套的泛型类型,比如 whereselect<trow, …, stop<...>> 这样。

顺着这个想法,再往下推几步,会自然落到一套具体的设计上。

把执行计划塞进类型系统

在 typedsql 里,每一个编译好的查询,最终都会变成一个封闭的泛型管道类型
这个管道是由一些基础节点拼出来的,比如:

  • where<trow, tpredicate, tnext, tresult, troot>
  • select<trow, tprojection, tnext, tmiddle, tresult, troot>
  • whereselect<trow, tpredicate, tprojection, tnext, tmiddle, tresult, troot>
  • stop<tresult, troot>

每个节点都实现了同一个接口:

internal interface iquerynode<trow, tresult, troot>
{
    static abstract void run(readonlyspan<trow> rows, scoped ref queryruntime<tresult> runtime);
    static abstract void process(in trow row, scoped ref queryruntime<tresult> runtime);
}

这里可以简单理解成:

  • run 是外面那一圈大循环(整体遍历);
  • process 是对单行执行的逻辑。

比如 where 节点大概长这样:

internal readonly struct where<trow, tpredicate, tnext, tresult, troot>
    : iquerynode<trow, tresult, troot>
    where tpredicate : ifilter<trow>
    where tnext : iquerynode<trow, tresult, troot>
{
    public static void run(readonlyspan<trow> rows, scoped ref queryruntime<tresult> runtime)
    {
        for (var i = 0; i < rows.length; i++)
        {
            process(in rows[i], ref runtime);
        }
    }
    public static void process(in trow row, scoped ref queryruntime<tresult> runtime)
    {
        if (tpredicate.evaluate(in row))
        {
            tnext.process(in row, ref runtime);
        }
    }
}

关键点在于:

  • 管道的形状,完全藏在这些类型参数里面;
  • 每个节点是一个只有静态方法的 struct —— 不需要创建实例,没有虚调用。

对 jit 来说,一旦这些泛型类型参数都被代入,这就是一张普通的静态调用图而已。

列和投影

查询总得运行在某种行类型 trow 上,这通常是你自己定义的一个 record/class/struct。

每一列会实现这样一个接口:

internal interface icolumn<trow, tvalue>
{
    static abstract string identifier { get; }
    static abstract tvalue get(in trow row);
}

举个简单的例子:

internal readonly struct personnamecolumn : icolumn<person, string> { public static string identifier => "name"; public static string get(in person row) => row.name; }

而投影(select 后面那部分)则实现:

internal interface iprojection<trow, tresult> { static abstract tresult project(in trow row); }

将选出某一列本身做成一个投影,可以这么写:

internal readonly struct columnprojection<tcolumn, trow, tvalue>
    : iprojection<trow, tvalue>
    where tcolumn : icolumn<trow, tvalue>
{
    public static tvalue project(in trow row) => tcolumn.get(row);
}

多列选择时,typedsql 会构造专门的投影,把结果拼成 valuetuple

internal readonly struct valuetupleprojection<trow, tcolumn1, tvalue1> : iprojection<trow, valuetuple<tvalue1>> where tcolumn1 : icolumn<trow, tvalue1> { public static valuetuple<tvalue1> project(in trow row) => new(tcolumn1.get(row)); } // … 一直到 7 列,然后通过一个“rest”再递归挂一个 iprojection

还是同样的模式:全是 struct,全是静态方法。

过滤器

过滤器的接口长这样:

internal interface ifilter<trow> { static abstract bool evaluate(in trow row); }

一个最常用的比较过滤器形式,是列 + 字面量:

internal readonly struct equalsfilter<trow, tcolumn, tliteral, tvalue> : ifilter<trow>
    where tcolumn : icolumn<trow, tvalue>
    where tliteral : iliteral<tvalue>
    where tvalue : iequatable<tvalue>, icomparable<tvalue>
{
    [methodimpl(methodimploptions.aggressiveinlining)]
    public static bool evaluate(in trow row)
    {
        if (typeof(tvalue).isvaluetype)
        {
            return tcolumn.get(row).equals(tliteral.value);
        }
        else
        {
            var left = tcolumn.get(row);
            var right = tliteral.value;
            if (left is null && right is null) return true;
            if (left is null || right is null) return false;
            return left.equals(right);
        }
    }
}

这里我们通过判断 tvalue 是值类型还是引用类型,来分别处理 null 的情况。.net 的 jit 能够识别这种模式,并且为值类型和引用类型分别特化并生成不同的代码路径,从而实际上并不存在任何的分支开销。

greaterthanfilterlessthanfiltergreaterorequalfilterlessorequalfilternotequalfilter 等等,都是同样的套路。

逻辑运算也是在类型层面组合的:

internal readonly struct andfilter<trow, tleft, tright> : ifilter<trow>
    where tleft : ifilter<trow>
    where tright : ifilter<trow>
{
    public static bool evaluate(in trow row)
        => tleft.evaluate(in row) && tright.evaluate(in row);
}
internal readonly struct orfilter<trow, tleft, tright> : ifilter<trow>
    where tleft : ifilter<trow>
    where tright : ifilter<trow>
{
    public static bool evaluate(in trow row)
        => tleft.evaluate(in row) || tright.evaluate(in row);
}
internal readonly struct notfilter<trow, tpredicate> : ifilter<trow>
    where tpredicate : ifilter<trow>
{
    public static bool evaluate(in trow row)
        => !tpredicate.evaluate(in row);
}

所以,一条 where 子句,最终就会变成一棵泛型过滤器类型树,每个节点只有一个静态 evaluate 方法。

值类型特化版字符串:valuestring

在 .net 里,string 是一个引用类型,这给 typedsql 带来了一些麻烦:.net 会对引用类型采用共享泛型在运行时做分发,而不是为 string 泛型实例化一个具体类型,这使得运行时会产生类型字典查找的开销。虽然这点开销不大,但是 typedsql 追求的是媲美手写循环的性能,所以我想尽量把热路径里涉及的类型都做成值类型。

于是我选择把字符串包在一个小的值类型里:

internal readonly struct valuestring(string? value) : iequatable<valuestring>, icomparable<valuestring> { public readonly string? value = value; public int compareto(valuestring other) => string.compare(value, other.value, stringcomparison.ordinal); public bool equals(valuestring other) { return string.equals(value, other.value, stringcomparison.ordinal); } public override string? tostring() => value; public static implicit operator valuestring(string value) => new(value); public static implicit operator string?(valuestring value) => value.value; }

再配一个适配器,把原来的 string 列变成 valuestring 列:

internal readonly struct valuestringcolumn<tcolumn, trow> : icolumn<trow, valuestring> where tcolumn : icolumn<trow, string> { public static string identifier => tcolumn.identifier; public static valuestring get(in trow row) => new(tcolumn.get(in row)); }

在内部,所有字符串列都统一成 valuestring,有几个好处:

  • 热路径里尽量是值类型,少一点引用类型的干扰;
  • 避开了泛型共享带来的类型字典查找开销。

对使用者来说,你照样写 string,而我的 typedsql 会在内部自动在边缘位置做封装/解封装,所以完全透明。

实现一个 sql 子集

typedsql 并不打算做成一个大而全的 sql 引擎,而是针对单表、内存内查询,设计了一个很小的 sql 方言:

支持这些语句:

  • select * from $
  • select col from $
  • select col1, col2, ... from $
  • where 支持:
    • 比较:=!=><>=<=
    • 布尔:andornot
    • 括号
  • 字面量支持:
    • 整数(如 42
    • 浮点数(如 123.45
    • 布尔(true / false
    • 单引号字符串('seattle',内部用 '' 转义)
    • null
  • 列名大小写不敏感
  • $ 代表当前行来源

整体解析流程很简单:

  • 先把 sql 字符串切成 token;
  • 再构建一棵小 ast,包含:
    • parsedquery:整体查询
    • selectionselectall 或者列名列表
    • whereexpression:筛选表达式
      • comparisonexpression:比较
      • andexpression:与
      • orexpression:或
      • notexpression:非
    • literalvalue:字面量
      • literalkind.integer + intvalue
      • literalkind.float + floatvalue
      • literalkind.boolean + boolvalue
      • literalkind.string + stringvaluestring?
      • literalkind.null

在这个阶段,整个系统其实完全不知道 c# 里面的类型是什么样的,列又是什么,只是单纯看作 sql 结构。

类型检查、以及这个字面量能不能用在那一列上之类的问题,会留到后面的编译阶段去做。

把字面量变成类型 —— 包括字符串

在这里,我想针对每一个 sql 语句都生成一份独特的类型,因此作为查询条件中的字面量,也必须变成类型参数的一部分。

于是,在 typesql 中,所有的字面量类型都实现同一个接口:

internal interface iliteral<t> { static abstract t value { get; } }

适用范围包括:

  • 整数(int
  • 浮点数(float
  • 字符(char
  • 布尔(bool
  • 字符串(这里是 valuestring,内部包 string?
  • ……未来还可以扩展更多

数值字面量

数值字面量的编码方式很直接:用 16 进制和位运算拼出来

先来一组 ihex 接口和 hex0hexf struct:

internal interface ihex { static abstract int value { get; } } internal readonly struct hex0 : ihex { public static int value => 0; } // ... internal readonly struct hexf : ihex { public static int value => 15; }

然后,一个整型字面量长这样:

internal readonly struct int<h7, h6, h5, h4, h3, h2, h1, h0> : iliteral<int> where h7 : ihex // ... where h0 : ihex { public static int value => (h7.value << 28) | (h6.value << 24) | (h5.value << 20) | (h4.value << 16) | (h3.value << 12) | (h2.value << 8) | (h1.value << 4) | h0.value; }

浮点数也是一样的 8 个十六进制数位,只不过最后用 unsafe.bitcast<int, float> 转回 float

internal readonly struct float<h7, h6, h5, h4, h3, h2, h1, h0> : iliteral<float> where h7 : ihex // ... { public static float value => unsafe.bitcast<int, float>( (h7.value << 28) | (h6.value << 24) | (h5.value << 20) | (h4.value << 16) | (h3.value << 12) | (h2.value << 8) | (h1.value << 4) | h0.value); }

字符则是 4 个十六进制数位:

internal readonly struct char<h3, h2, h1, h0> : iliteral<char>
    where h3 : ihex
    // ...
{
    public static char value
        => (char)((h3.value << 12)
                | (h2.value <<  8)
                | (h1.value <<  4)
                |  h0.value);
}

字符串字面量:类型的链表!

字符串字面量就比较有趣了。

这里我选择在类型层面构建一条字符链表,用接口 istringnode 来描述:

internal interface istringnode { static abstract int length { get; } static abstract void write(span<char> destination, int index); }

有三个实现:

  • stringend:字符串的结尾(长度 0);
  • stringnull:表示 null 字符串(长度 -1);
  • stringnode<tchar, tnext>:当前一个字符 + 剩余部分。
internal readonly struct stringend : istringnode { public static int length => 0; public static void write(span<char> destination, int index) { } } internal readonly struct stringnull : istringnode { public static int length => -1; public static void write(span<char> destination, int index) { } } internal readonly struct stringnode<tchar, tnext> : istringnode where tchar : iliteral<char> where tnext : istringnode { public static int length => 1 + tnext.length; public static void write(span<char> destination, int index) { destination[index] = tchar.value; tnext.write(destination, index + 1); } }

有了这样的类型链表,我们就可以基于某个 istringnode,构造出真正的 valuestring

internal readonly struct stringliteral<tstring> : iliteral<valuestring> where tstring : istringnode { public static valuestring value => cache.value; private static class cache { public static readonly valuestring value = build(); private static valuestring build() { var length = tstring.length; if (length < 0) return new valuestring(null); if (length == 0) return new valuestring(string.empty); var chars = new char[length]; tstring.write(chars.asspan(), 0); return new string(chars, 0, length); } } }

stringliteral<tstring> 就是一个 iliteral<valuestring>,它的 value 在类型初始化时算好并缓存下来,所以只需要计算一次,后续访问都是直接读静态字段,非常高效。

把字符串塞进类型

literaltypefactory.createstringliteral 负责把字符串字面量转换成这样一个类型:

public static type createstringliteral(string? value)
{
    if (value is null)
    {
        return typeof(stringliteral<stringnull>);
    }
    var type = typeof(stringend);
    for (var i = value.length - 1; i >= 0; i--)
    {
        var chartype = createchartype(value[i]); // char<...>
        type = typeof(stringnode<,>).makegenerictype(chartype, type);
    }
    return typeof(stringliteral<>).makegenerictype(type);
}
  • 比如我们有一个字面量 'seattle',整个流程大致是:
  • 解析阶段读到 'seattle',生成一个 literalvalue
    • kind == literalkind.string
    • stringvalue == "seattle"
  • 编译阶段根据列的类型判断:这是个字符串列,于是对应的运行时类型是 valuestring
    • 调用 createstringliteral("seattle")
    • 初始 type = typeof(stringend)
  • 从右到左遍历每个字符:
    • 'e' → 得到一个 char<…> 类型(4 个十六进制数位对应 unicode)
    • type = stringnode<char<'e'>, stringend>
  • 'l' 再往前:
    • type = stringnode<char<'l'>, stringnode<char<'e'>, stringend>>
    • 一直重复:'t''t''a''e''s'……
  • 最终得到类似这样一个类型:
stringnode<char<'s'>,
  stringnode<char<'e'>,
    stringnode<char<'a'>,
      stringnode<char<'t'>,
        stringnode<char<'t'>,
          stringnode<char<'l'>,
            stringnode<char<'e'>, stringend>>>>>>>>

最后再用 stringliteral<> 把它包起来:

stringliteral<
  stringnode<char<'s'>,
    stringnode<char<'e'>,
      ...
    >
  >
>

这一整个封闭泛型类型,就是字面量 'seattle' 的类型版本。

而过滤器在需要值的时候,只是简单地访问 tliteral.value,再通过 tstring.length 和 tstring.write 复原出一个 valuestring("seattle"),其中复原通过静态类型的缓存完成,借助类型系统的力量,每一个独立的字面量都会产生一个单独的类型实例,我们的字面量就缓存在那个类型的静态字段里,从而避免了一切运行时的计算开销。

null 字符串字面量

null 的处理稍微特殊一点:

  • 写类似 where team != null 这种代码时,解析器会把它识别为 literalkind.null
  • 对字符串列来说,createstringliteral(null) 会返回 typeof(stringliteral<stringnull>)
  • stringnull.length == -1,于是 stringliteral<stringnull>.value 直接返回 new valuestring(null)

这样一来,null 和 "" 在类型层面和运行时都可以被区分开。

字面量工厂

上面这些编码最后都归到一个工厂类里统一封装:

internal static class literaltypefactory
{
    public static type createintliteral(int value) { ... }
    public static type createfloatliteral(float value) { ... }
    public static type createboolliteral(bool value) { ... }
    public static type createstringliteral(string? value) { ... }
}

sql 编译阶段会根据两方面信息来调用它:

  • 列的运行时类型(intfloatboolvaluestring);
  • 字面量的种类(integerfloatbooleanstringnull)。

最终的效果就是:where 子句里每一个字面量,都会变成一个具体的 iliteral<t> 类型,值直接嵌在类型参数里。

搭好整个管道类型

到目前为止,我们已经有了:

  • 一棵解析出来的查询(select + where);
  • 一份 schema,把列名映射到具体的 icolumn<trow, tvalue> 实现;
  • 一套机制,把字面量变成 iliteral<t> 类型。

sql 编译器接下来要做的就是,把这些东西变成:

  • 一个封闭的管道类型 tpipeline,它实现 iquerynode<trow, truntimeresult, troot>
  • 一个运行时结果类型 truntimeresult
  • 一个对外公开的结果类型 tpublicresult

编译 select

先看选择部分。

select *

最简单的情况就是:select * from $

这时候:

  • 运行时结果类型 = 行类型本身:truntimeresult = trow
  • 公共结果类型也是 trow
  • 管道尾部就是一个 stop<trow, trow> 节点。

大致逻辑如下:

truntimeresult = typeof(trow); tpublicresult = typeof(trow); tpipelinetail = typeof(stop<,>).makegenerictype(truntimeresult, typeof(trow));

select col / select col1, col2, ...

当有明确列投影时,步骤稍微多一点:

  • select col
    • 根据列名解析出对应的 columnmetadata
  • 决定它的运行时值类型:
    • 如果列类型本身不是 string,运行时类型就跟它一致;
    • 如果是 string,运行时类型改为 valuestring
    • 构建一个 columnprojection<truntimecolumn, trow, truntimevalue>
  • select col1, col2, ...
    • 分别解析每一列;
    • 构造一个 valuetupleprojection,返回一个 valuetuple<...>,里面放运行时类型;
    • 同时记录一份公共 valuetuple<...> 类型,用声明的 clr 类型(如 string)。

最后,无论是一列还是多列,都会在 stop 前面再加一个 select 节点:

select<trow, tprojection, stop<...>, tmiddle, truntimeresult, troot> → stop<...>

这个节点内部会调用投影的静态 project 方法,再把结果转交给 stop.process 处理。

编译 where

where 子句以递归方式编译成类型。

布尔结构

给定一个解析后的 whereexpression 树:

  • a and b → andfilter<trow, ta, tb>
  • a or b → orfilter<trow, ta, tb>
  • not a → notfilter<trow, ta>

编译器做的事情,大概是对这棵树一层层往下调自己的方法:

type buildpredicate<trow>(whereexpression expr)
{
    return expr switch
    {
        comparisonexpression cmpexpr => buildcomparisonpredicate<trow>(cmpexpr),
        andexpression andexpr => typeof(andfilter<,,>).makegenerictype(typeof(trow), buildpredicate<trow>(andexpr.left), buildpredicate<trow>(andexpr.right)),
        orexpression orexpr => typeof(orfilter<,,>).makegenerictype(typeof(trow), buildpredicate<trow>(orexpr.left), buildpredicate<trow>(orexpr.right)),
        notexpression notexpr => typeof(notfilter<,>).makegenerictype(typeof(trow), buildpredicate<trow>(notexpr.expression)),
        _ => throw …
    };
}

比较表达式

每一个叶子比较表达式,比如:

city = 'seattle'
salary >= 180000
team != null

都会变成一个具体的过滤器类型:

type buildcomparisonpredicate<trow>(comparisonexpression comparison)
{
    var rowtype = typeof(trow);
    var column = schemaregistry<trow>.resolvecolumn(comparison.columnidentifier);
    var runtimecolumntype      = column.getruntimecolumntype(rowtype);
    var runtimecolumnvaluetype = column.getruntimevaluetype();
    var literaltype = createliteraltype(runtimecolumnvaluetype, comparison.literal);
    var filterdefinition = comparison.operator switch
    {
        comparisonoperator.equals        => typeof(equalsfilter<,,,>),
        comparisonoperator.greaterthan   => typeof(greaterthanfilter<,,,>),
        comparisonoperator.lessthan      => typeof(lessthanfilter<,,,>),
        comparisonoperator.greaterorequal=> typeof(greaterorequalfilter<,,,>),
        comparisonoperator.lessorequal   => typeof(lessorequalfilter<,,,>),
        comparisonoperator.notequal      => typeof(notequalfilter<,,,>),
        _ => throw …
    };
    return filterdefinition.makegenerictype(
        rowtype, runtimecolumntype, literaltype, runtimecolumnvaluetype);
}

以 city = 'seattle' 为例,如果那一列是字符串列,那么:

  • 运行时列类型是:valuestringcolumn<personcitycolumn, person>
  • 运行时值类型是:valuestring
  • 字面量类型,则是通过 createstringliteral("seattle") 得到的某个 stringliteral<somestringnode<…>>

最后组合出一个过滤器类型:

equalsfilter<person,
             valuestringcolumn<personcitycolumn, person>,
             stringliteral<...>,
             valuestring>

到这一步,我们就可以把一个 where 节点挂到管道上了:

where<trow, tpredicate, tnext, truntimeresult, troot> → ...

把 where 和 select 融合起来

直接这么拼出来的管道是正确的,但在性能上还能再优化一点:
where 和 select 其实可以合并成一步。

typedsql 里有一个很小的优化器,会去找这样的模式:

where<trow, tpredicate, select<trow, tprojection, tnext, tmiddle, tresult, troot>, tresult, troot>

一旦发现,就把它替换成:

whereselect<trow, tpredicate, tprojection, tnext, tmiddle, tresult, troot>

这个融合节点的实现如下:

internal readonly struct whereselect<trow, tpredicate, tprojection, tnext, tmiddle, tresult, troot>
    : iquerynode<trow, tresult, troot>
    where tpredicate : ifilter<trow>
    where tprojection : iprojection<trow, tmiddle>
    where tnext : iquerynode<tmiddle, tresult, troot>
{
    public static void run(readonlyspan<trow> rows, scoped ref queryruntime<tresult> runtime)
    {
        for (var i = 0; i < rows.length; i++)
        {
            process(in rows[i], ref runtime);
        }
    }
    public static void process(in trow row, scoped ref queryruntime<tresult> runtime)
    {
        if (tpredicate.evaluate(in row))
        {
            var projected = tprojection.project(in row);
            tnext.process(in projected, ref runtime);
        }
    }
}

于是像下面这种常见的查询:

select name from $ where city = 'seattle'

最终就会是:

whereselect<...> → stop<...>

也就是说:一个循环里完成过滤和投影,不需要再分两趟。并且,我们的优化器还能识别更复杂的嵌套结构,尽可能地把 where 和 select 融合在一起,减少中间步骤,提升性能。而这并不需要复杂的优化算法,只需要简单地把泛型参数取出来重新带入到新的融合类型即可,实现起来非常简单。

结果转换

管道把所有行跑完之后,最后还得把结果以某种形式“交出去”。

一个查询的入口长这样:

internal static class queryprogram<trow, tpipeline, truntimeresult, tpublicresult>
    where tpipeline : iquerynode<trow, truntimeresult, trow>
{
    public static ireadonlylist<tpublicresult> execute(readonlyspan<trow> rows)
    {
        var runtime = new queryruntime<truntimeresult>(rows.length);
        tpipeline.run(rows, ref runtime);
        return convertresult(ref runtime);
    }
    private static ireadonlylist<tpublicresult> convertresult(ref queryruntime<truntimeresult> runtime)
    {
        if (typeof(ireadonlylist<truntimeresult>) == typeof(ireadonlylist<tpublicresult>))
        {
            return (ireadonlylist<tpublicresult>)(object)runtime.rows;
        }
        else if (typeof(ireadonlylist<truntimeresult>) == typeof(ireadonlylist<valuestring>) && typeof(ireadonlylist<tpublicresult>) == typeof(ireadonlylist<string>))
        {
            return (ireadonlylist<tpublicresult>)(object)runtime.asstringrows();
        }
        else if (runtimefeature.isdynamiccodesupported && typeof(truntimeresult).isgenerictype && typeof(tpublicresult).isgenerictype)
        {
            return runtime.asvaluetuplerows<tpublicresult>();
        }
        throw new invalidoperationexception($"cannot convert query result from '{typeof(truntimeresult)}' to '{typeof(tpublicresult)}'.");
    }
}

可以看到主要有三种情况:

  • 运行时结果类型和公共结果类型一模一样→ 直接把 rows 返回就行。
  • 运行时内部用的是 valuestring,外面希望看到 string→ 调用 asstringrows,它会把内部的 valuestring[] 包装一下,对外返回 string?(靠隐式转换)。
  • 两边都是某种 valuetuple 形状→ 用 asvaluetuplerows<tpublicresult>(),底层交给 valuetupleconverthelper 去做拷贝和字段转换。

valuetupleconverthelper:用动态 il 在元组之间搬运字段#

valuetupleconverthelper<tpublicresult, truntimeresult> 的职责是:

  • 在两个兼容形状的 valuetuple 之间搬运字段;
  • 识别并处理 string ↔ valuestring 的转换;
  • 如果 valuetuple 有 rest(嵌套元组),要递归下去做同样的事情。

它在类型初始化时,会生成一个 dynamicmethod 来做拷贝:

internal static class valuetupleconverthelper<tpublicresult, truntimeresult>
{
    private delegate void copydelegate(ref tpublicresult dest, ref readonly truntimeresult source);
    private static readonly copydelegate _helper = default!;
    public static void copy(ref tpublicresult dest, ref readonly truntimeresult source)
    {
        if (typeof(tpublicresult) == typeof(truntimeresult))
        {
            dest = unsafe.as<truntimeresult, tpublicresult>(ref unsafe.asref(in source));
        }
        else
        {
            _helper.invoke(ref dest, in source);
        }
    }
    static valuetupleconverthelper()
    {
        // 构造 dynamicmethod 和 il,按字段复制,
        // 若发现 string <-> valuestring,就做对应转换,
        // 遇到 rest 字段时递归。
    }
}

这样,运行时内部可以用一个对自己更舒服的元组类型,比如 (valuestring, int, valuestring, …),而外面看到的则是 (string, int, string, …),两者之间通过这一层帮助类桥接,成本也很低。这使得查询过程可以最大化利用值类型的泛型特化优势,同时对外还不需要暴露这些内部细节,达到了性能和易用性的平衡。

不过需要注意的是,这一块用到了动态代码生成,所以在一些受限环境(比如 aot)下可能无法使用,因此 typedsql 会在编译阶段检查这一点,确保只有在支持动态代码的环境下,才允许使用这种元组转换。否则的话,就只能退回到直接让运行时结果类型和公共结果类型一致的方式。

整体流程:编译并执行查询

站在使用者的角度,入口一般会是这样的:

var compiled = queryengine.compile<person, string>( "select name from $ where city != 'seattle'");

compile<trow, tresult> 在内部会做这么几件事:

  • 解析 sql,生成 parsedquery
  • 把 sql 编译成:
    • 管道类型 tpipeline
    • truntimeresult
    • tpublicresult
  • 检查 tpublicresult 是否和你指定的 tresult 一致;
  • 构造 queryprogram<trow, tpipeline, truntimeresult, tpublicresult> 这个类型;
  • 找到它的静态方法 execute(readonlyspan<trow>)
  • 把它变成一个委托,塞进 compiledquery<trow, tresult>

compiledquery<trow, tresult> 本身只是包了一个委托:

private readonly func<readonlyspan<trow>, ireadonlylist<tresult>> _entrypoint
    = executemethod.createdelegate<func<readonlyspan<trow>, ireadonlylist<tresult>>>();

然后对外暴露:

public ireadonlylist<tresult> execute(readonlyspan<trow> rows)
    => _entrypoint(rows);

得益于 .net 10 对委托的逃逸分析、去虚拟化和内联等优化,这一层委托调用可以说几乎没有任何开销。

在 jit 看来,一旦 compile 做完这些准备工作,以后每次 execute 就只是:

  • 一次直接的静态调用;
  • 调入一个所有类型参数已经封死的泛型方法;
  • 这个方法里面再调用一串全是 struct 和静态方法组成的管道。

最终编译出来的类型,你既可以直接拿去执行,也可以把它输出到代码里然后通过 nativeaot 编译成原生二进制文件,一套代码同时支持 jit 和 aot!

使用和性能测试

快速上手

和很多轻量级查询库类似,typedsql 的打开方法是:

定义你的行类型,例如:

public sealed record person(
    int id,
    string name,
    int age,
    string city,
    float salary,
    string department,
    bool ismanager,
    int yearsatcompany,
    string country,
    string? team,
    string level);

为每一列实现一个 icolumn<person, tvalue>

把这些列注册到 person 对应的 schema 里;

然后就可以编译并运行查询,例如:

// 编译一次
var wellpaidmanagers = queryengine.compile<person, person>(
    """
    select * from $ 
    where department = 'engineering' 
    and ismanager = true 
    and yearsatcompany >= 5 
    and salary > 170000 
    and country = 'us'
    """);
// 针对不同数据集多次执行
var result = wellpaidmanagers.execute(allpeople.asspan());

要是你只需要一部分列,也可以返回元组:

var seniortitles = queryengine.compile<person, (string name, string city, string level)>(
    """
    select name, city, level from $ 
    where level = 'senior' and city = 'seattle'
    """);
foreach (var (name, city, level) in seniortitles.execute(allpeople.asspan()))
{
    console.writeline($"{name} in {city} [{level}]");
}

所有重活——解析 sql、字面量编码、在类型系统里搭管道——都发生在编译查询这一步。
之后每次 .execute,都只是跑一遍已经专门化好的静态管道,没有任何的运行时分发,没有任何的虚拟调用,不存在任何的反射和装箱,完全是 jit 能看懂的强类型、零分配代码,从而实现极高的性能。

简单性能对比

typedsql 的目标并不是炫技用类型,而是想试试看:在保持 sql 风格外壳的情况下,我们能让生成的代码离一个手写循环有多近。

一个非常简单的 benchmark 就是拿三个方案做对比:

  • 一条 typedsql 查询;
  • 一条等价的 linq 查询;
  • 一段手写的 foreach 循环。

任务内容:

  • 过滤出 city == "seattle" 的行;
  • 返回它们的 id

typedsql 编译出来的类型大概是这样:

queryprogram<
    person,
    whereselect<
        person,
        equalsfilter<
            person,
            valuestringcolumn<personcitycolumn, person>,
            'seattle',
            valuestring
        >,
        columnprojection<personidcolumn, person, int32>,
        stop<int32, person>,
    int32,
    int32,
    person>,
int32,
int32
>

让我们来看看 ryujit 为我们的查询方案生成了什么样的机器码:

g_m000_ig01:                ; prologue
       push     r15
       push     r14
       push     rdi
       push     rsi
       push     rbp
       push     rbx
       sub      rsp, 40
       mov      rbx, rcx
g_m000_ig02:                ; 分配结果数组
       mov      esi, dword ptr [rbx+0x08]
       mov      edx, esi
       mov      rcx, 0x7ffe71f29558
       call     corinfo_help_newarr_1_vc
       mov      rdi, rax
       xor      ebp, ebp
       mov      rbx, bword ptr [rbx]
       test     esi, esi
       jle      short g_m000_ig06
g_m000_ig03:                ; 初始化循环变量
       xor      r14d, r14d
g_m000_ig04:                ; 循环体
       lea      r15, bword ptr [rbx+r14]
       mov      rcx, gword ptr [r15+0x08]
       mov      rdx, 0x16eb0400d30
       mov      rdx, gword ptr [rdx]
       mov      rdx, gword ptr [rdx+0x08]
       cmp      rcx, rdx
       je       g_m000_ig12
       test     rcx, rcx
       je       short g_m000_ig05
       test     rdx, rdx
       je       short g_m000_ig05
       mov      r8d, dword ptr [rcx+0x08]
       cmp      r8d, dword ptr [rdx+0x08]
       je       short g_m000_ig08
g_m000_ig05:                ; 更新循环计数器
       add      r14, 72
       dec      esi
       jne      short g_m000_ig04
g_m000_ig06:                ; 产生结果对象
       mov      rcx, 0x7ffe72227600
       call     corinfo_help_newsfast
       mov      rbx, rax
       lea      rcx, bword ptr [rbx+0x08]
       mov      rdx, rdi
       call     corinfo_help_assign_ref
       mov      dword ptr [rbx+0x10], ebp
       mov      rax, rbx
g_m000_ig07:                ; epilogue
       add      rsp, 40
       pop      rbx
       pop      rbp
       pop      rsi
       pop      rdi
       pop      r14
       pop      r15
       ret
g_m000_ig08:                ; 字符串长度比较
       lea      rax, bword ptr [rcx+0x0c]
       add      rdx, 12
       mov      ecx, dword ptr [rcx+0x08]
       add      ecx, ecx
       mov      r8d, ecx
       cmp      r8, 10
       je       short g_m000_ig10
g_m000_ig09:                ; 字符串内容慢速比较
       mov      rcx, rax
       call     [system.spanhelpers:sequenceequal(byref,byref,nuint):bool]
       jmp      short g_m000_ig11
g_m000_ig10:                ; 字符串内容快速比较
       mov      rcx, qword ptr [rax]
       mov      rax, qword ptr [rax+0x02]
       mov      r8, qword ptr [rdx]
       xor      rcx, r8
       xor      rax, qword ptr [rdx+0x02]
       or       rcx, rax
       sete     al
       movzx    rax, al
g_m000_ig11:                ; 处理比较结果
       test     eax, eax
       je       short g_m000_ig05
g_m000_ig12:                ; 把匹配的 id 写入结果数组
       mov      ecx, dword ptr [r15+0x30]
       lea      rax, bword ptr [rdi+0x10]
       lea      edx, [rbp+0x01]
       mov      r15d, edx
       movsxd   rdx, ebp
       mov      dword ptr [rax+4*rdx], ecx
       mov      ebp, r15d
       jmp      g_m000_ig05

注意看 g_m000_ig08 的 r8, 10,这里的 10 就是字符串字面量 'seattle' 的长度,jit 直接把我们的字符串字面量的长度常量嵌进了机器码里;进一步当长度匹配时,jit 又生成了代码跳转到 g_m000_ig10,这段代码专门处理长度为 10 的字符串的快速比较路径。也就是说,jit 不仅把字面量的值嵌进去了,还根据它生成了专门的代码路径!

再注意看循环计数器的更新部分,g_m000_ig05 里的 add r14, 72,这里的 72 就是 sizeof(person),jit 直接把行类型的大小常量也嵌进去了,避免了运行时的计算;而 dec esi 更是直接把递增的循环优化成了递减,减少了一次比较指令。

上述代码的逻辑等价于:

int length = elements.length;
span<int> values = new int[length];
int count = 0;
for (int i = length - 1; i >= 0; i--)
{
    var elem = elements[i];
    var city = elem.city;
    if (city == null)
        continue;
    if (city.length == 10 && city == "seattle")
    {
        values[length - 1 - count] = elem.id;
        count++;
    }
}
return values[..count];

看到了吗?跟你手写的循环几乎一模一样!我们的抽象完全被 jit 优化的一干二净!

上个跑分结果:

methodmeanerrorstddevgen0code sizeallocated
typedsql10.953 ns0.0250 ns0.0195 ns0.0051111 b80 b
linq27.030 ns0.1277 ns0.1067 ns0.01483,943 b232 b
foreach9.429 ns0.0417 ns0.0326 ns0.0046407 b72 b

可以看到:typedsql 在时间和分配上无限逼近 foreach,远远超过即使是在 .net 10 中已经被高度优化后的 linq 的性能。

这也符合我们对它内部结构的预期:

  • 查询管道是类型层级的,结构在编译期就定死
  • 列、投影、过滤全是值类型 + 静态方法
  • 字符串统一走 valuestring 热路径
  • 字面量则通过 iliteral<t> 嵌在类型参数里
  • 所有这些都让 jit 能够把代码特化、展开、内联,最终生成和手写循环几乎一样的机器码

尾声

typedsql 只是一个简单的内存查询引擎实验。它只是围绕一个很具体的问题:c# 的类型系统到底能让我们把多少查询逻辑搬过去,.net 又能针对这些类型生成多快的代码?

于是,在 typesql 中,我们实现了:

  • 把列、投影、过滤全都表示成带静态方法的 struct,并通过接口的静态抽象成员来约束它们的行为
  • 把它们组合成一串嵌套的泛型管道节点(whereselectwhereselectstop
  • 把数字和字符串字面量都编码成类型(iliteral<t>

最后得到的是一个小小的、看起来很像 sql 的内存查询引擎;而在 jit 眼里,它其实就是一套可以进行高度优化的、类型特化后的循环。

因此答案是肯定的:.net 的类型系统完全可以用来表达图灵完备的逻辑,并且借助 jit 编译器的强大优化能力,生成非常高效的代码。

展望未来的应用,诸如查询引擎、dsl 编译器、甚至是语言运行时等复杂系统,都可以通过类似的方式来实现,从而在保持灵活性的同时,最大化性能。而你甚至不需要实现任何的代码生成后端,只要利用好 c# 的泛型和静态成员,就能让 jit 帮你完成大部分的工作。而把构建好的类型输出成代码文件,再通过 nativeaot 编译成原生二进制文件,也同样是可行的。编写一次,同时支持 jit 和 aot,两全其美。并且不同于 c++ 的模板和 constexpr,我们的引擎是完全支持来自外部的动态输入的,而不需要在编译时确定一切!

本项目的代码已经开源在 github 上,欢迎点赞和 star:https://github.com/hez2010/typedsql

到此这篇关于在 c# 类型系统上实现一个 sql 查询引擎的文章就介绍到这了,更多相关c#  sql 查询引擎内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com