kevy
本页内容

表(TABLE.* / table_*

表是一份具名的、可校验的声明,在声明期就被编译成 kevy 已有的索引与视图原语。TABLE.DECLARE 接受一个前缀、类型化的列、二级索引和复合排序路径,产出普通的具名索引;查询期没有任何新东西在跑。这是引擎的常设规则(Law 3)的用户版表述:kevy 从不为查询做规划——访问路径由你命名,而表是把一整族访问路径一次性命名的省力写法。

TABLE.DECLARE user PREFIX u: PK id
    COLUMN id str COLUMN name str COLUMN age i64
    COLUMN dept str COLUMN email str
    INDEX age range VALUES dept name
    INDEX email unique
    ORDERPATH by_dept_age ON dept THEN age DESC

IDX.QUERY user.by_dept_age WHERE dept EQ eng LIMIT 20

行仍是前缀下的 hash 键,与从前完全一样——声明一张表不改变你写入的任何方式(HSET u:1 name alice age 30 …),也不施加 schema:缺少某个已声明列的行,就是该列为 NULL 的行(每个索引本来就有的缺字段语义)。这份声明为你买到的是编译好的访问路径、一个 VERIFY 面,以及对它们全体的单动词生命周期管理。

正在从手工维护的索引迁移?先读 table-migration.md——八条在生产上付过学费的经验,以及"表为什么存在"的实测漂移数字(89% 从未写入、76% 从未移除)。

声明绝不 panic。 TABLE.DECLARE / Store::table_declare 对每一个非法 spec——未知列、重名、缺 PK,无论什么——都以一个具名错误作答,而被拒绝的声明什么也不安装。这是一条硬保证,由 compile_table 自行校验来强制,并被持续 fuzz(table_spec):你启动路径上的一个坏 spec 是一行日志,不是一个重启循环。

声明模型

TABLE.DECLARE 把每个子句编译成一个具名索引:

子句编译产物
`INDEX <col> range\unique [VALUES <col>…]`前缀上一个名为 <table>.<col> 的标量索引,VALUES 列按行存储(类型来自列声明)
ORDERPATH <name> ON <col> [DESC] [THEN <col> [DESC]]…一个名为 <table>.<orderpath> 的复合 range 索引——每行一个保序字节键

编译出的名字共用一个命名空间——<table>.<col><table>.<orderpath>——所以与被索引列同名的 ORDERPATH 在声明期就被按名拒绝。编译是服务端与嵌入式 store 共用的单一实现(dispatch oracle 在 CI 里对两个面做逐字节比对),并且是原子的:任何错误都导致什么也不装——不存在半声明的表。

编译出的索引做的一切,与手工 IDX.CREATE 声明的完全相同:同样的回填行为、同样的 -INDEXBUILDING 纪律、同样的 sidecar 持久化、同样的预算拒绝(indexes.md)。TABLE.DROP 删除表和它编译出的所有索引。

语法

TABLE.DECLARE name PREFIX p PK col
    COLUMN name i64|f64|str [COLUMN ...]
    [INDEX col range|unique [VALUES col ...]] ...
    [ORDERPATH name ON col [DESC] [THEN col [DESC]] ...] ...
    [WINDOW col SPAN n BUCKET n]   # 滑动热窗——见下文
    [AUTODECLARE n]    # 允许引擎替你补最多 n 条路径——见下文
TABLE.ENSURE ...       # TABLE.DECLARE 的开机形态——见下文
TABLE.REPLACE ...      # 显式的 drop + declare + 重建
TABLE.DROP name        # drops the table + its compiled indexes; 1|0
TABLE.LIST             # name/prefix/pk + column/index/orderpath counts
TABLE.VERIFY name      # component fsck + a bounded column spot check

滑动窗口

WINDOW <col> SPAN <n> BUCKET <n> 在一个 i64 列上声明一个滑动的热窗。SPANBUCKET以该列自己的单位计的普通整数——引擎从不假定时间基准,所以这一列可以是 epoch 秒、epoch 毫秒、序列号,任何随数据变旧而单调的东西。边界按整桶推进;被驱逐的一桶变成一个冷段。

两条声明期拒绝,都具名:

  • 窗口列必须是一个已声明的 i64 列;
  • 它必须有一条访问路径,其树尾能免费回答 max(col)——要么是它上面的单列 INDEX <col>,要么是首列就是它、且升序的 ORDERPATH。没有就拒绝声明(WINDOW needs an access path on '<col>')——这同时也保证了每张窗口表都能经由同一条路径服务跨窗范围查询。

今天在滑的是:窗口列上的那条单列 INDEX。 随着边界推进(整桶地),索引树中落在窗外的前缀会移进 segs-<shard>/ 下不可变的冷段文件——索引内存缩小,而普通的 RANGE / COUNT 查询在热+冷上继续作答,与一条未窗口化的索引逐字节相同(包括冷行的重写、删除与复活;语义等价的 e2e 把这一点钉住了)。冷索引段是派生的溢出,不是真相:行仍然是热键空间里普通的 hash,重启时只是重建并重新滑动。

两处边缘,都是显式的:带子句的查询(FILTER / SORT / DISTINCT / FACET)在一条已有冷段的索引上会具名拒绝,直到带子句的冷路径落地为止——绝不给一个静默不完整的答案;以及由窗口列领头的 ORDERPATH 满足声明条件,但今天还不滑。纯内存部署(没有数据目录)接受这条声明,只是保持全热。

开机模式:ensure

开机时声明才是常态——schema 住在你的代码里,每次进程启动都陈述一遍。TABLE.ENSURE(嵌入面:Store::table_ensure,返回 TableEnsure::{Created, Unchanged})接受与 TABLE.DECLARE 相同的文法,是它的幂等形态:

  • 表不存在 → 声明并构建:Created
  • spec 完全相同 → 无操作的成功:Unchanged(线上回 +UNCHANGED)。此后每次开机都走这条路。
  • spec 不同点名哪里变了的具名拒绝COLUMNS / INDEXES / ORDERPATHS / PREFIX / PK)——绝不静默重建。重建是对整个前缀域的全量回填,这么昂贵的事必须被点名要求:TABLE.REPLACE(嵌入面:table_replace)就是那个要求——显式 drop + declare + 重建,并在丢弃旧表之前先验证新 spec,坏的替换会让旧表继续服务。

裸的 TABLE.DECLARE 保持严格形态:重声明既有名字是错误。开机用 ensure,迁移用 replace,重名即 bug 的场合用 declare

  • 列类型是 i64 | f64 | str——即标量索引的类型。其余一切(时间戳、布尔、枚举)由应用编码进这三种之一,粗粒度映射被明说而不是被藏起来(kevy-sql 会对每个被转换的列打印一条说明)。
  • PK 指向一个已声明的列;它是文档加一个 VERIFY 面——行仍按键寻址,和今天完全一样。serial 式的 id 分配是一份配方(序列配方),不是引擎特性。
  • 最多 64 张表;每一种结构性拒绝都有名字(重复列、未知的 VALUES 列、名字冲突……),从不静默。

TABLE.VERIFY 在调用那一刻、双向地现算每一个计数器(4.1——此前 coerce_failures 是生命周期累计值,还把缺失列也吞了进去,没法和旁边现算的 drift 对读):

  • index→rowentries / bytes / duplicates / drift / checked——每条持有的 entry 都从它的行重新推导。
  • row→index:前缀下的每一行按 cause 分类——走过的 rowscoerce_failures(存在但转换失败)、excluded(复合的 str 分量超 255 字节)、absent(分量列缺失:设计上的 NULL,不是错误),以及 missing(能推导出值却没有 entry 的行——忘了这张表存在的那个 writer,漂移走查在结构上看不见的唯一一类)。

一切正常时 entries = rows − excluded − absent − coerce_failures 成立;每种排除原因都有了自己的名字,而不是一个解释不了的 entries 差值。另有有界抽查(每 shard 最多 64 行)断言*出现了的*已声明列可转换。任何组成索引仍在回填时,回答 -INDEXBUILDING

复合 ORDERPATH 语义

ORDERPATH 把复合排序配方——ORDER BY a, b DESC 的遍历——机械化成一个真正的复合索引:每行一条保序字节串,于是一棵 B-tree 就能像关系型复合索引那样回答查询。规则如下:

  • WHERE 取前导前缀。WHERE a EQ x [b EQ y …] [RANGE c min max] 必须从头按声明顺序点名复合索引的列:一段等值前缀,然后在*下一列*上最多一个 range;其后全部不受约束(经典的复合 B-tree 语义)。点名一个非前缀列是具名错误——从不是一次扫描。
  • RANGEWHERE 内是终结的——它后面不能再跟任何东西,因为 range 之后的条件无法表示为一次连续遍历。
  • 每个分量的 DESC 体现在存储顺序里,所以 ON dept THEN age DESC 让每个部门的行从最老最大的那一端翻页,不需要重排。
  • 缺少任一分量列的行被排除在复合索引之外(转换失败同理)——它在其他所有访问路径上完全可见。长于 255 字节str 分量同样使该行被排除:这与关系型 B-tree 对索引行大小的同类上限一致,也是让 range 边界保持精确的前提。最多 8 个分量。
  • WHERE 同样作用于 IDX.COUNT;在一个没有声明复合列的索引上使用它会被按名拒绝。
  • TABLE.VERIFYduplicates 非零意味着这个 ORDERPATH 不是全序——在所有分量上打平的行会塌缩成一条 entry,游标翻页会在平局边界跳行或重复。给复合的末尾加一个有界的决胜列(数值 id,或自然键的定宽哈希)——像裸 Message-ID 这样的无界字符串不会决胜,只会踩中 255 字节的排除上限。
IDX.QUERY user.by_dept_age WHERE dept EQ eng                  # all eng, age DESC
IDX.QUERY user.by_dept_age WHERE dept EQ eng RANGE age 31 46  # eng, 31<=age<=46

查询表

查询仍然是对编译名的 IDX.QUERY——表不新增查询动词,因为引擎在查询期不求值任何东西:

IDX.QUERY user.age RANGE 25 45                          # driving range
IDX.QUERY user.email EQ d@x                             # unique point lookup
IDX.QUERY user.age RANGE 0 100
    FILTER dept EQ eng SORT name ASC LIMIT 20 OFFSET 20 # clauses on VALUES
IDX.QUERY user.age RANGE 0 100 FACET dept
IDX.QUERY user.by_dept_age WHERE dept EQ eng LIMIT 20 FIELDS name email

FILTER / SORT / DISTINCT / FACET / OFFSET 读的是索引VALUES 声明时存下的列——与它们的全文检索原型相同的子句语法、相同的跨 shard 精确语义(text-search.md):FILTER 在翻页之前生效,所以一条排得很深的合格行仍能进入 LIMITFACET 统计整个匹配集;缺失值在两个方向都排在最后。点名一个索引没有存储的字段是一个错误,错误里列出它存了哪些。驱动谓词永远是被索引的 range / EQ / WHERE——不存在没有索引的 WHERE

OFFSET 是这里唯一一个越大越贵的子句,也是这台引擎里唯一一个随分片增多而变慢的面:每个分片都必须取 limit + offset 条命中,因为没有哪个分片能知道自己的命中里哪些能活过全局归并,于是原点为了交回 limit 条要实体化 (limit + offset) × 分片数 条。在范围内 30,000 行上返回 LIMIT 20 实测:OFFSET 1000 在 1 个分片上 1.53 ms,在 8 个分片上 6.90 ms。请用返回的游标分页——每页恒定,而且位置稳定(rds-workloads.md)。

index-only 查询零行触达。一条 FILTER / SORT / COUNT 查询完全从常驻 RAM 的索引作答——行读计数器在门禁套件里被断言 == 0bench/tablegate.sh)。这正是这两个特性一起设计时瞄准的分层协同:开着透明分层存储时,一张全冷的表以零磁盘读服务 index-only 查询,只有最后的 hydration 页(FIELDS …)付冷读——每行一次、批量提交。不带 VALUES 列的索引在内存与查询路径上,与从未声明过这些列的 store 上的索引逐字节相同(零成本-未声明门禁)。

AUTODECLARE:你没写的那些路径

查询一个你从未建索引的列会被按名字拒绝。那次拒绝同时也是一条关于你负载的事实,IDX.ADVISE 会把反复撞上它的形状列出来。AUTODECLARE n 的意思是:当某个形状被拒得足够多、并且它落在我已声明的列上时,就替我把这条路径声明掉——最多 n 条。

它的每一处都是刻意有界的:

  • 不问就不开。 没有这个子句就没有这个循环。这不是默认行为。
  • 上限就是你写的那个数。 预算用完之后查询照样被拒,形状留在 IDX.ADVISE 里等你读——引擎不会悄悄抬高自己的上限。
  • 只在已声明的列上。 形状若指向表没有声明的列,就永远无法落地;IDX.ADVISE 仍会报告它,答案还是你的。
  • 只增不删。 删索引是人的动作。猜错的最坏后果是有界的内存浪费,绝不会是丢掉一条路径。
  • 看得见。 这样建出的索引在 IDX.LIST 里带 auto 标记,表的 spec 里也留着账本——你随时能回读哪些是你写的、哪些是引擎补的。
  • 不在查询答案里发生。 越过阈值的那次查询照样拿到它的错误,下一次才会发现路径正在建。

"足够多"是同一形状被拒 16 次。 这个数是常量,不是旋钮:每表可调的阈值恰恰就是本引擎声称不需要的那种逐负载调优,而形状到得慢的负载无论如何都要先付 16 次拒绝。之所以写在这里,是因为一个盯着 IDX.ADVISE 纳闷"怎么还没动静"的运维应该拿到这个数——而不是因为它可以设。

这不是查询计划器,这个区别就是全部要点:引擎从不替你选走哪条路径——你的查询自己点名。AUTODECLARE 只是在你的邀请下、在你的预算内、在你看得见的地方扩展声明。查询期仍是一条铁律:跑已声明的路径,其余按名字拒绝。

NULL、唯一性,以及什么被强制

  • NULL = 缺失字段。没有任何列是必填的;缺少被索引列的行只是不在那个索引里。没有引擎级 CHECK、默认值或 NOT NULL——约束是配方(约束配方,原子块)。
  • 表层的唯一性是校验而非强制unique 索引就是 IDX.CREATE KIND unique 建的那道围栏(indexes.md——预留模式让它免于竞态),TABLE.VERIFY 报告 duplicates,而不是引擎事后拒绝你的写入。

它不是什么

按拒绝来陈述,因为引擎是按名拒绝而不是勉强近似:没有运行期 SQL(对服务端发 TABLE.DECLARE,不是 CREATE TABLE),没有查询期 join(视图的 VIA 解引用除外,views.md),没有 HAVING / 子查询 / 表达式没有引擎强制的约束。上述每一项的 SQL 到 kevy 映射在 rds-workloads.md,可用的配方在 cookbook.md,schema 编译路径见下。

kevy-sql:编译 schema,而不是发送 schema

kevy-sql(及其 kevy-cli sql 面)是一个声明期编译器——像迁移工具一样,把一份 PG/MySQL 方言的 schema 文件读一次,产出显式声明:

kevy-cli sql compile schema.sql                          # print the declarations
kevy-cli sql compile schema.sql --apply --url 127.0.0.1:6004
kevy-cli sql plan schema.sql                             # 每条查询会变成什么

compileplan 读同一份文件,回答的却是两个问题。compile 是构建期:它产出的是要执行的命令,所以遇到一条服务不了的视图就是错误、就停在那里。plan 是迁移那天——它报告每一条查询的去向,因为*"你这 40 条里 34 条能跑,另外 6 条各缺什么"*才是一个拿着 schema 来的人真正在问的:

$ kevy-cli sql plan shop.sql
2 table(s) to declare:
  users
  orders

5 quer(ies) — 3 served, 2 not

  served:
    paid_orders              orders.status
    recent_by_user           orders.user_id_created_at
    by_email                 users.email

  not served:
    line 25    by_total
      view 'by_total': WHERE (total EQ) matches no declared access path — add: CREATE INDEX ON orders (total)
    line 28    everything
      view 'everything': a view with no WHERE would scan the table — kevy has no scans; add a driving predicate, or page an index directly (IDX.QUERY orders.<col> RANGE …)

plan: 2 of 5 quer(ies) need a declaration change before this schema moves

只要有查询服务不了,它就以非零退出。那不是警告:一条没有声明路径的查询根本跑不了,所以它在 schema 改掉之前一直挡着这次迁移。而 DDL 本身解析不了仍然是普通错误——对一份不成立的 schema 没有计划可言。

  • CREATE TABLETABLE.DECLARE(类型粗粒度映射到 i64|f64|str,每个映射都如实标注)。
  • CREATE [UNIQUE] INDEXINDEX 子句;PG 的 INCLUDE 覆盖列 → 存储的 VALUES;多列索引 → 一个 ORDERPATH
  • 常量的单表 CREATE VIEW … AS SELECT → 一个引擎视图;带参数的 → 一张查询卡:一条现成的 IDX.QUERY 模板,$N 槽位由你的应用运行时填入。
  • 编译器同样不做规划:它把你的视图与你声明过的访问路径做匹配,匹配不上时告诉你该补哪条声明(add: CREATE INDEX ON t (dept, age)),而不是发明一次扫描。临时 SQL、join、子查询、ORGROUP BY 等一律带 line:col 拒绝,并指向替代它的配方。

端到端的完整演练——一份真实的 users/orders/order_items schema 被编译、应用、查询——见schema 迁移配方

嵌入式

类型化 API,同一套编译,进程内不需要文本语法。声明类型——TableSpecTableIndexOrderPath——由门面重导出(4.1):一切从 kevy_embedded import,永远不要依赖内部 crate。

use kevy_embedded::{TableEnsure, TableSpec};

match store.table_ensure(spec)? {    // 开机动词:验证、编译、同步构建
    TableEnsure::Created => {}
    TableEnsure::Unchanged => {}     // 同 spec 重启时无操作
}
let tables = store.table_list();
let report = store.table_verify_report(b"user")?;  // 具名的 fresh 计数
assert_eq!(report.per_index[0].missing, 0);        //   + 抽查
store.table_drop(b"user");

wire 形式(db.cmd("TABLE.DECLARE", …))同样可用,用完全相同的共享语法解析——服务端 / 嵌入式的逐字节一致由 dispatch oracle 在 CI 里钉死。

性能

门禁钳制,及其测量状态照实说:一致性 / 一致对齐 / 拒绝 / index-only 断言在本树全部绿色通过(bench/tablegate.sh)。吞吐钳制——10 M 行下索引点查 p99 ≤ 1 ms、10 M 行下 FILTER+SORT+LIMIT-20 翻页 p95 ≤ 5 ms、3 个索引加已声明 VALUES 的写入税 ≤ 15 %(对比裸 HSET)——是 perfgate 指标行,其基线待专用基准机记录bench/capacity-envelope.sh 负责记录)。在记录之前,它们是目标,不是测量结果——本页不会把它们当作结果引用。

写入成本是标准的索引税:每个编译出的索引对每次匹配写入付一次字段读加一次段更新;空目录的成本是一个不会命中的分支。

参见

  • indexes.md——表所编译到的索引引擎。
  • tiering.md——与表一起设计的另一半:索引热、行冷。
  • rds-workloads.md——完整的 SQL 词汇映射(什么可编译、什么是配方、什么被拒绝)。
  • cookbook.md——复合排序与 schema 迁移配方。
  • table-migration.md——从手工维护的索引迁移过来,八课。
  • views.md——同一批索引上的具名组合。