kevy
kevy 6.3.0 · 主存储

让读始终是一次查表

“这个客户名下所有还没关闭的订单。”“这个购物车里有几件东西。”这类读,一个应用一秒钟要做上千次,而在关系型数据库里,每一次都是一条背后跟着查询计划器的查询。kevy 可以直接把答案备好。

为什么合适

键值存储被挡在应用数据之外,通常只因为一句反对:可是我需要按 key 以外的东西去查。这句反对是对的,而二级索引正是为它准备的。

索引是声明出来的,不是建出来的。你写清楚 key 的前缀和字段,写路径会把它维持在最新。一个带过滤的列表于是重新变回一次查表——没有计划器,没有扫描,没有查询。

视图更进一步,在写入时就把聚合维持在最新,于是一个计数、一个合计是读出来的,不是算出来的。这正是大多数应用真正在向 ORM 索要的东西,也正是它们的数据库忙成那样的原因。

而且整张表可以一次声明。TABLE.DECLARE 接受类型化列、二级索引和复合 ORDER BY 路径,在声明期把它们编译成具名索引——kevy-sql 用你手上现成的 PG/MySQL schema 文件做同一件事。引擎仍然不做任何规划、不强加任何 schema;join 和运行期 SQL 依旧按名拒绝。

按字段查,而不是按 key 查

“客户 881 的所有订单”始终是一次查表:每个要查的字段声明一个索引,照常写入,按值来读。

你的数据,照你本来的方式写
HSET order:1001 customer 881 status open  total 4400
HSET order:1002 customer 881 status paid  total 8400
HSET order:1003 customer 902 status open  total 1200
每个要按它查的字段,建一个索引
IDX.CREATE idx:cust   ON PREFIX order: FIELD customer TYPE i64 KIND range
IDX.CREATE idx:status ON PREFIX order: FIELD status   TYPE str KIND range
那次本来会变成查询的读
IDX.QUERY idx:cust EQ 881
-> 1) "0"                       # cursor
   2) 1) "order:1001"  2) "881"
      3) "order:1002"  4) "881"
两个条件一起查
IDX.QUERY COMPOSE AND idx:cust EQ 881 idx:status EQ open
-> 1) "0"
   2) 1) 1) "order:1001"

成本与限制 索引的账是每次写的时候付的,不是读的时候——对读多的服务路径这是对的交易,对写多的日志是错的。这里没有 join,以后也不会有:索引回答的是“哪些 key 匹配这些字段”,不是“把这两个集合连起来”。如果你的读确实需要 join,就把它留在 Postgres 里——关系型负载那一页写着哪些读属于这种情况。

把一个持续更新的答案备好

一个带过滤、带排序的列表,由写路径一直维持着——读永远不用重算它,因为它从来没有过期过。

在同样的索引上声明视图
VIEW.CREATE v:open881 QUERY ( AND idx:cust EQ 881 idx:status EQ open )  ORDER BY idx:cust
-> OK

括号是各自独立的参数。

读它——这里什么都不用算
VIEW.QUERY  v:open881
-> 1) "0"
   2) 1) "order:1001"  2) "881"

成本与限制 视图是写路径上一直要干的活:每一次对匹配 key 的写都会更新它,不管今天有没有人来读。只为应用真正在对外服务的那些读声明视图,不再服务的就删掉。视图要组合的那些索引,必须先存在。

整张表,一次声明

一张关系型表的读路径——索引化的 WHERE、余下的过滤、ORDER BY、翻页、COUNT——由一条声明编译到具名索引上。或者直接由你现成的 schema 文件编译。

列、索引、排序路径,一条声明写完
TABLE.DECLARE orders PREFIX order: PK id COLUMN id str COLUMN customer i64 COLUMN status str COLUMN total f64 INDEX status range VALUES total customer ORDERPATH by_customer ON customer THEN total DESC
-> OK

行仍是前缀下的普通 hash——缺列就是 NULL;kevy-cli sql compile schema.sql 会从 CREATE TABLE / CREATE INDEX 生成这一行。

在存储的列上过滤和计数——一行都不用读
IDX.QUERY orders.status EQ open FILTER total RANGE 2000 inf LIMIT 20
-> 1) "0"
   2) 1) "order:1001"  2) "open"

IDX.COUNT orders.status EQ open
-> (integer) 2
ORDER BY customer, total DESC 的那次遍历
IDX.QUERY orders.by_customer WHERE customer EQ 881 LIMIT 20 FIELDS status total

一个复合索引,用关系型复合索引的方式回答它——每个客户的订单,从大到小,不需要重排。

成本与限制 没有运行期 SQL,也没有 join。服务端把 SELECT 当作未知命令拒绝;kevy-cli sql compile 在构建期把 PG/MySQL schema 文件变成上面这些声明,并按名拒绝 JOIN、子查询和 GROUP BY,同时指向替代它们的配方。唯一性是校验而非强制,约束是配方而非引擎检查。开着分层存储时,index-only 查询即使全部行都是冷的也只读 RAM——只有最后的 FIELDS 页会读冷行,每行一次。

接下来

指南

类型化列和索引声明一次,然后像查表一样查它。

去读 →

指南

在 kevy 上做设计

习惯了用表思考的人,怎么改成用 key 思考。

去读 →

指南

二级索引

它们怎么建、代价是什么,以及怎么看懂一次查询的执行计划。

去读 →

参考

关系型负载

每一种关系型模式,以及在这里做它的真实代价。

去读 →