Flux 02: 语法导览

在进入 parser、runtime 和查询执行之前,先需要一张“这门 Flux 子集到底怎么写”的地图。

这个项目不是官方 Flux 的完整实现,而是一个可运行、可测试、可继续扩展的 Flux-like 子集。它已经覆盖常见查询、函数、标准库和 table pipeline,但仍有一些语法和语义边界需要明确。本文站在使用者视角,不讲 parser 怎么实现,只讲当前支持哪些写法、它们是什么意思,以及哪些地方暂时不要期待完整官方行为。

如果你已经熟悉 Flux,可以把这篇当作项目方言说明;如果你第一次接触 Flux,可以先读这篇,再去看后面的 parser 和 runtime 实现。

一个 Flux 文件通常由 package、import、option、变量定义和表达式组成:

package demo

import "array"
import regexp "regexp"

option location = {zone: "UTC", offset: 0s}

threshold = 80.0

array.from(rows: [
    {_time: 2024-01-01T00:00:00Z, host: "edge-1", _value: 91.2},
])
    |> filter(fn: (r) => r._value > threshold)

当前实现支持:

Flux 01: 项目目标与整体架构

这几年我一直在写一个 C++20 实现的 Flux 查询语言实验项目:cpp/pl/flux。它不是为了完整复刻 InfluxData 官方 Flux,也不是为了立刻做成生产级数据库,而是为了回答一个更工程化的问题:如果从零实现一个可运行、可调试、可测试的 Flux 子集,需要哪些模块,它们之间应该如何分层?

很多语言项目会停在 parser demo:能把源码解析成 AST,能打印一棵树,已经很有成就感。但查询语言更麻烦。它不仅要理解表达式,还要处理表流、group key、窗口、聚合、数据源、输出格式、IDE 体验和性能退化。只要其中一个边界没想清楚,后面就很容易把优化逻辑、运行时逻辑和标准库逻辑搅在一起。

目前这个项目已经不只是一个 parser。它包含 scanner、parser、AST dump、表达式解释器、运行时值模型、标准库 package、表流执行、SQLite/MySQL connector、查询计划、Page-based physical executor、CLI、REPL、LSP 和 conformance examples。换句话说,它已经有了一个小型语言运行时和单机查询引擎的骨架。

这一篇是整个系列的入口。我会先讲项目目标和能力边界,再讲一条 Flux 查询从源码到输出会经过哪些层,最后给出代码阅读和运行方式。后面的文章会沿着这些边界逐层展开。

Flux 很适合拿来做查询引擎实验,因为它同时具备三种特征。

第一,它是一门表达式语言。它有字面量、对象、数组、函数、闭包、条件表达式、正则、字符串插值和命名参数。实现它时,必须认真处理 scanner、parser、AST、runtime value、environment 和 function call。

第二,它是一门 pipeline 查询语言。|> 不是普通装饰语法;它决定了用户如何把数据源、过滤、投影、聚合和输出串起来。实现 pipe 后,运行时必须决定:一个表算子是立即执行,还是追加到 lazy logical plan 等待 optimizer 处理?

第三,它围绕 table stream 建模。Flux 查询不是简单返回一个数组,而是返回一组 logical table,每张表有 group key、列、行和结果名。这让它天然适合探索查询引擎里的 table pipeline、window、aggregate、join 和 connector pushdown。

一段很小的 Flux 查询就能覆盖这些问题:

import "array"

array.from(rows: [
    {_time: 2024-01-01T00:00:00Z, host: "edge-1", region: "east", usage: 91},
    {_time: 2024-01-01T00:01:00Z, host: "edge-2", region: "west", usage: 42},
])
    |> filter(fn: (r) => r.usage > 80)
    |> keep(columns: ["host", "usage"])
    |> yield(name: "hot_hosts")

为了让它真正跑起来,项目至少需要完成这些事情:

一文讲透 AI 九大核心概念

AI 圈每天都在冒新词。但你真的能准确说出每一个概念的确切含义吗?本文从最底层的工程视角出发,逐层拆解,帮你建立完整的 AI 技术认知体系。


LLM,全称 Large Language Model,中文译作大语言模型,简称大模型

当前几乎所有主流大模型,底层都基于同一套架构——Transformer。这个架构最早由 Google 团队在 2017 年提出,对应的论文标题叫做《Attention Is All You Need》(注意力机制就是全部所需)。

虽然 Google 发明了这把火,但真正点燃全世界的是 OpenAI。

  • 2022 年底,ChatGPT(GPT-3.5)横空出世,成为第一个真正达到"可用级别"的大模型;
  • 2023 年 3 月,GPT-4 发布,把 AI 的能力天花板拉到了新高度。

GPT 系列是这轮 AI 浪潮的绝对引路人。时至今日,GPT 系列依然非常强大,如 GPT-4.5 仍是行业标杆之一。但如今 AI 赛道早已不是 OpenAI 一家独大,Claude、Gemini 等后起之秀都在各自擅长的领域与之同台竞技。

大模型本质上是一个文字接龙游戏

举个例子,你向大模型提问:「今天天气怎么样」

  1. 模型接收这句话,经过内部运算,预测下一个概率最高的词:「今」
  2. 模型把「今」追加到输入后面,再预测下一个词:「天」
  3. 如此循环,直到预测出特殊的结束标记

最终输出:「今天天气晴朗」

这就是大模型最底层的生成原理——一个词一个词地输出答案,因为它就是这么运作的

LLM 文字接龙流程图
LLM 文字接龙流程图


大模型本质上是一个庞大的数学系统,接收的是数字,输出的也是数字,根本不认识人类书写的文字

因此,在人类和大模型之间必须有一个中间人来做翻译,这个中间人就叫做 Tokenizer。它负责两件事:

  • 编码(Encode):把文字变成数字
  • 解码(Decode):把数字还原成文字

编码分两步:

BloomFilter中的数学推导

mm: 表示 Bloom Filter bit array 的长度;
kk: 表示 hash 函数个数;
nn: 表示插入元素的个数;

假设 hash 函数以等概率选择 bit array 的下标,那么某次 hash 后,某个特定 bit 位未被设置为 1 的概率为 11m1 - \frac{1}{m}。经过 kk 个 hash 函数之后,该 bit 位仍未被设置为 1 的概率为:

(11m)k \left(1 - \frac{1}{m}\right)^k

在插入 nn 个元素之后,某个 bit 位仍然没有被设置为 1 的概率为:

使用LLVM的libFuzzer进行fuzzy test

LLVM libFuzzer 是 LLVM 生态系统中的一个fuzzy test工具,用于自动化地发现软件程序中的漏洞和错误。它通过生成大量的随机输入数据并观察程序的行为来进行fuzzy test。 libFuzzer 是一个基于内存的fuzzy test引擎,使用 LLVM 的插桩技术和代码优化功能来提高测试效率和覆盖率。

以下是 libFuzzer 的一些功能特点:

  1. 自动化fuzzy test:libFuzzer 提供了一种自动化的fuzzy test方法,可以生成大量的随机输入数据,并在每个输入上运行目标函数进行测试。它通过观察程序的崩溃、断言失败、未定义行为等反馈来发现潜在的问题。
  2. 内存安全性:libFuzzer 通过使用 AddressSanitizer (ASan) 和 UndefinedBehaviorSanitizer (UBSan) 等工具来确保fuzzy test过程中的内存安全性。这有助于检测和报告内存错误、缓冲区溢出、使用已释放内存等问题。
  3. 代码覆盖率分析:libFuzzer 使用 LLVM 提供的代码覆盖率分析技术,帮助确定已经执行过的代码路径和未执行的代码区域。这有助于评估测试的质量和覆盖范围,并帮助发现潜在的漏洞。
  4. 快速收敛:libFuzzer 使用一种称为 “回退”(Backoff)的策略,以更快地收敛到程序中的漏洞。它会根据测试结果调整输入数据的变异程度,使得能够更快地发现问题并生成更有潜力的测试用例。
  5. 灵活性和可定制性:libFuzzer 提供了多种选项和配置参数,使用户能够根据自己的需求进行定制。例如,可以设置最大测试时间、内存消耗限制、覆盖率报告等。
  6. 多线程支持:libFuzzer 支持多线程执行,可以利用多核处理器并行进行fuzzy test,加快测试速度。

下面是一个使用 libFuzzer 的简单示例

首先我们有一个 test_fuzzy.cpp:

#include <cstddef>
#include <cstdint>

void DoSomethingWithData(const uint8_t* data, std::size_t size) {
  int* p = nullptr;
  if (size < 10) return;
  if (data[0] == 'h' && data[1] == 'e' && data[2] == 'l' && data[3] == 'l' && data[4] == '0') {
    *p = 42;
  }
  return;
}

extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, std::size_t size) {
  DoSomethingWithData(data, size);
  return 0;
}

使用 clang++进行编译:

322.零钱兑换

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。 计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。 你可以认为每种硬币的数量是无限的。

  1. 定义状态:

dp[i]dp[i]表示用所给的面值的硬币凑成金额ii所需的最少的硬币个数。

  1. 设计状态转移方程:
\[ \forall coin \in coins, 当 i \geqslant coin,且 dp[i - coin] \neq -1 时, dp[i] = std::min(dp[i], dp[i - coin] + 1) \]
  1. 初始化:

对于 amount 为 0 的情况,所需的硬币数也为 0,因此:dp[0]=0dp[0] = 0

  1. 递推求解:

这里我们使用了一个小技巧,默认将dpdp的值都填充为INT_MAX,这样就可以避免对-1这个负数做特殊的判断和处理,相当于我们用INT_MAX 来代理了-1

#include <vector>
#include <climits>

class Solution {
public:
    int coinChange(const std::vector<int>& coins, int amount) {
        std::vector<int> dp(amount + 1, INT_MAX);
        dp[0] = 0;
        for (int i = 1; i <= amount; ++i) {
            for (int coin : coins) {
                if (coin <= i && dp[i - coin] != INT_MAX) {
                    dp[i] = std::min(dp[i], dp[i - coin] + 1);
                }
            }
        }
        return dp[amount] == INT_MAX ? -1 : dp[amount];
    }
};

70.爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

这道题是一个非常典型而且很简单的动态规划题目。我们可以根据动态规划题目解题的一般思路来分析:

  1. 定义状态:

dp[i]dp[i]表示爬到第ii级楼梯的不同方法数。由于每次可以选择爬 11 级或者 22 级楼梯, 所以爬到第 ii 级楼梯的方法数等于爬到第 i1i-1 级楼梯和第 i2i-2 级楼梯的方法数之和。 根据这个关系,我们可以使用动态规划的方式从 11 级楼梯开始逐步计算到第 nn 级楼梯的方法数,最终返回 dp[n]dp[n]即为结果。

  1. 设计状态转移方程:
dp[i]=dp[i1]+dp[i2]dp[i] = dp[i - 1] + dp[i - 2]
  1. 初始化:

由题目可知,dp[0]=0dp[0] = 0; dp[1]=1dp[1] = 1,这里需要特别注意的是,dp[2]dp[0]+dp[1]dp[2] \ne dp[0] + dp[1],而是dp[2]=2dp[2] = 2,所以dp[2]dp[2]也应该作为初始值

416.分割等和子集

给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

由题可知,数组nums非空,所以分割后的两个子集也必然非空,由于都是正整数,所以nums中元素之和必然为偶数。

这道题是典型的 01 背包问题,假设dp[i][j]dp[i][j]表示nums中前ii个元素是否包含和为jj的子集,那么:

  1. nums[i] = j的时候,dp[i][j] = true
  2. nums[i] > j的时候,dp[i][j] = dp[i - 1][j]
  3. nums[i] < j的时候,dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]]
#include <vector>
#include <numeric>

class Solution {
public:
    bool canPartition(const std::vector<int>& nums) {
        int size = nums.size();
        int sum = std::accumulate(nums.begin(), nums.end(), 0);
        if (size == 1 || (sum & 1) == 1) return false;
        int target = sum / 2;
        std::vector<std::vector<bool>> dp(size + 1, std::vector<bool>(target + 1));
        for (int i = 1; i <= size; ++i) {
            for (int j = 1; j <= target; ++j) {
                int num = nums[i - 1];
                if (num == j) dp[i][j] = true;
                else if (num > j) dp[i][j] = dp[i - 1][j];
                else dp[i][j] = dp[i - 1][j] | dp[i - 1][j - num];
            }
        }
        return dp[size][target];
    }
};

84. 柱状图中最大的矩形

给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。 求在该柱状图中,能够勾勒出来的矩形的最大面积。

主要思路是,遍历每个柱子,然后往柱子左右两边寻找比当前柱子矮的位置,从而计算出,以当前柱子为高度,所能围成的最大面积。 然后将这些面积中最大的值返回即可。暴力求解的时间复杂度为O(n^2)

不过我尝试过各种暴力求解,在 leetcode 中提交后都会超时。

class Solution {
public:
    int largestRectangleInHistogram(const std::vector<int>& inputs) {
        std::size_t n = inputs.size();
        int max_area = 0;
        for (int i = 0; i < n; ++i) {
            int min_height = INT_MAX;
            for (int j = i; j < n; ++j) {
                min_height = std::min(min_height, inputs[j]);
                max_area = std::max(max_area, min_height * (j - i + 1));
            }
        }
        return max_area;
    }
};
class Solution {
public:
    int largestRectangleInHistogram(const std::vector<int>& inputs) {
        int n = inputs.size();
        std::stack<int> stk;
        int ret = 0;
        for (int i = 0; i < n; ++i) {
            while (!stk.empty() && inputs[stk.top()] > inputs[i]) {
                int w = i;
                int h = inputs[stk.top()];
                stk.pop();
                if (!stk.empty()) {
                    w = i - stk.top() - 1;
                }
                ret = std::max(ret, w * h);
            }
            stk.push(i);
        }
        while (!stk.empty()) {
            int w = n;
            int h = inputs[stk.top()];
            stk.pop();
            if (!stk.empty()) {
                w = n - stk.top() - 1;
            }
            ret = std::max(ret, w * h);
        }

        return ret;
    }
};

155.最小栈

设计一个支持 pushpoptop 操作,并能在常数时间内检索到最小元素的栈。 实现 MinStack 类: MinStack() 初始化 void push(int val) 将元素推入堆栈 void pop() 删除堆栈顶部的元素 int pop() 获取堆栈顶部的元素 int getMin() 获取堆栈中的最小元素

这道题首先要满足堆栈的特性 LIFO,其次是能够在常数时间内获取当前栈中最小的元素,因此我们可以用堆栈保存 个二元组,二元组的第一个元素是存入栈中的值,第二个元素是当前元素作为栈顶元素的时候,栈中的最小值。有 了这个思路,代码实现起来就很简单了。

class MinStack {
public:
    MinStack() = default;

    void push(int val) {
        stack_.emplace(val, std::min(val, getMin()));
    }

    void pop() {
        stack_.pop();
    }

    void top() {
        return stack_.top().first;
    }

    int getMin() {
        if (stack_.empty()) return INT_MAX;
        return stack_.top().second;
    }
private:
    std::stack<std::pair<int, int>> stack_;
};