索引
索引类型
Goose 内置两种索引类型。也可通过扩展定义索引。
Min-Max 索引(Zonemap)
min-max index(又称 zonemap 或 block range index)会为所有通用数据类型列_自动创建_。
Adaptive Radix Tree(ART)
Adaptive Radix Tree (ART) 主要用于保证主键约束,以及加速点查与高选择性(如 < 0.1%)查询。ART 索引可通过 CREATE INDEX 手动创建,也会在 UNIQUE 或 PRIMARY KEY 列上自动创建。
警告:目前 ART 索引在创建时必须能放入内存。若创建阶段索引无法放入内存,请避免创建 ART 索引。
由扩展定义的索引
Goose 通过 spatial 扩展支持用于空间索引的 R-tree。
持久化
min-max 索引和 ART 索引都会持久化到磁盘。
CREATE INDEX 与 DROP INDEX 语句
要创建 ART 索引,请使用 CREATE INDEX 语句。
要删除 ART 索引,请使用 DROP INDEX 语句。
ART 索引的限制
ART 索引会在另一位置维护数据的二级副本。 维护这份副本会增加处理复杂度,尤其是与事务结合时。 因此,目前对“同时存在于二级索引中的数据修改”有一些限制。
正如预期,索引会显著影响性能:会降低加载和更新速度,但会加速部分查询。详情请参阅性能指南。
UPDATE 语句中的约束检查
对已建索引列或无法原地更新的列执行 UPDATE 时,会被改写为先 DELETE 原行再 INSERT 新行。
该改写会带来性能影响,尤其在宽表上,因为会重写整行而非仅受影响列。
此外,这会导致下述 UPDATE 约束检查限制。
其他 DBMS(如 PostgreSQL)也存在类似限制。
在下面例子中,行数超过 Goose 默认 vector size(2048)。
UPDATE 会改写为 DELETE + INSERT,
且该改写按数据块(2048 行)在处理流水线中进行。
当把 i = 2047 更新为 i = 2048 时,系统尚未看到后续“2048 也会变成 2049”的数据块,
因此会抛出约束冲突。
CREATE TABLE my_table (i INTEGER PRIMARY KEY);
INSERT INTO my_table SELECT range FROM range(3_000);
UPDATE my_table SET i = i + 1;
Constraint Error:
Duplicate key "i: 2048" violates primary key constraint.
一种变通方案是将 UPDATE 拆为 DELETE ... RETURNING ... 再 INSERT,
并增加额外逻辑(临时)保存 DELETE 结果。
这些语句应放在一个事务中执行(BEGIN ... COMMIT)。
下面是命令行客户端中的示例。
CREATE TABLE my_table (i INTEGER PRIMARY KEY);
INSERT INTO my_table SELECT range FROM range(3_000);
BEGIN;
CREATE TEMP TABLE tmp AS SELECT i FROM my_table;
DELETE FROM my_table;
INSERT INTO my_table SELECT i FROM tmp;
DROP TABLE tmp;
COMMIT;
在其他客户端中,你可能可以直接获取 DELETE ... RETURNING ... 的结果,
再用于后续 INSERT ...,或使用 Goose 的 Appender(若该客户端支持)。
FOREIGN KEY 约束检查过于激进
满足以下条件时会触发该限制:
- A table has a
FOREIGN KEYconstraint. - There is an
UPDATEon the correspondingPRIMARY KEYtable, which Goose rewrites into aDELETEfollowed by anINSERT. - The to-be-deleted row exists in the foreign key table.
如果条件成立,你会遇到意外约束错误:
CREATE TABLE pk_table (id INTEGER PRIMARY KEY, payload VARCHAR[]);
INSERT INTO pk_table VALUES (1, ['hello']);
CREATE TABLE fk_table (id INTEGER REFERENCES pk_table(id));
INSERT INTO fk_table VALUES (1);
UPDATE pk_table SET payload = ['world'] WHERE id = 1;
Constraint Error:
Violates foreign key constraint because key "id: 1" is still referenced by a foreign key in a different table. If this is an unexpected constraint violation, please refer to our foreign key limitations in the documentation
根因是 Goose 目前不支持“前瞻”。
在执行 INSERT 时,系统不知道该外键值稍后会在 UPDATE 改写流程中被重新插入。
并发事务中 DELETE 之后的约束检查
为便于理解索引限制,先简要说明 Goose 的索引存储。
定义基于索引的约束,或执行 CREATE [UNIQUE] INDEX 时,Goose 会创建键列表达式与行 ID 的物理二级副本。
该结构存储在所属表的物理存储中。
注意:约束冲突仅与 primary key、foreign key、UNIQUE 索引相关。
在事务执行中,只有当旧事务不再依赖某值时,Goose 才能修改或删除该值。
也就是说,必须等所有仍需看到旧值的旧事务结束。
Goose 通过 MVCC 保证这种事务语义。
在表存储层,每个事务都知道自己是否对某值可见:
若事务开始于该变更/删除的 COMMIT 之前,则可见;开始于其后则不可见。
索引目前尚不具备同等能力。
假设全局索引中有一个 value-row_id 对,且发生了修改/删除该值的 COMMIT。
该条目会继续对新事务可见,直到所有更老且依赖它的事务结束。
这种行为引出两个主要限制,详见下文。
另需注意,该限制也会扩展到多表并发访问。 若表 X 与表 Y 在同一 schema,X 上较老的读事务可能导致 Y 上连续变更出现写写冲突。
这些限制的长期方案是让索引支持事务时间戳追踪。 但目前 Goose 尚未在索引层完整实现 MVCC。
变通方案
由于这是 Goose 当前限制,暂时没有纯 SQL 层面的 workaround。 若某带索引表存在并发读写,你需要在应用层加锁。 即并发读进行时,多个写操作需要等待读结束后再执行。
你也可以考虑不使用索引。
在某些场景下,Goose 的 MERGE INTO 可能更适合你的需求。
UNIQUE 约束检查过于激进
在唯一性约束下,某些应成功的插入会失败:
// Assume "someTable" is a table with an index enforcing uniqueness.
tx1 = gooseTxStart()
someRecord = goose(tx1, "SELECT * FROM someTable USING SAMPLE 1 ROWS")
tx2 = gooseTxStart()
gooseDelete(tx2, someRecord)
gooseTxCommit(tx2)
// At this point someRecord is deleted, but tx1 still needs visibility on that record.
// Thus, the ART index is not updated, so the following query fails with a constraint error:
tx3 = gooseTxStart()
gooseInsert(tx3, someRecord)
gooseTxCommit(tx3)
// Following this, the above insert succeeds because the ART index was allowed to update.
gooseTxCommit(tx1)
注意:在旧版 Goose 中,这类场景某些变体可能看起来可行(未抛约束异常),
尤其是 UPSERT。
但这些变体会导致错误状态,因为约束检查错误地基于“已删除值”进行。
更多细节可参考下面基于 SQLLogic 测试框架的复现脚本。
statement ok
CREATE SCHEMA IF NOT EXISTS schema__test
concurrentloop threadid 0 3
statement ok
CREATE TABLE IF NOT EXISTS schema__test.test_table${threadid} (
id VARCHAR PRIMARY KEY,
available_actions VARCHAR[],
subscriber_ids VARCHAR[]
)
loop i 0 5
statement ok
INSERT OR REPLACE INTO schema__test.test_table${threadid} VALUES ('test:scope/test-worker-${threadid}:test-id-0', ['read', 'write', 'delete'], ['sub-{threadid}', 'sub-{threadid}+1'])
endloop
endloop
FOREIGN KEY 约束检查不够严格
在外键约束下,某些应失败的插入会成功:
// Setup: Create a primary table with a UUID primary key and a secondary table with a foreign key reference.
primaryId = generateNewGUID()
conn = gooseConnectInMemory()
// Create tables and insert the initial record in the primary table.
goose(conn, "CREATE TABLE primary_table (id UUID PRIMARY KEY)")
goose(conn, "CREATE TABLE secondary_table (primary_id UUID, FOREIGN KEY (primary_id) REFERENCES primary_table(id))")
gooseInsert(conn, "primary_table", {id: primaryId})
// Start a transaction tx1, which reads from primary_table.
tx1 = gooseTxStart(conn)
readRecord = goose(tx1, "SELECT id FROM primary_table LIMIT 1")
// Note: tx1 remains open, holding locks/resources.
// Outside of tx1, delete the record from primary_table.
gooseDelete(conn, "primary_table", {id: primaryId})
// Try to insert into secondary_table, which has a foreign key reference to the now-deleted primary record.
// This succeeds because tx1 is still open and the constraint isn't fully enforced yet.
gooseInsert(conn, "secondary_table", {primary_id: primaryId})
// Commit tx1, releasing any locks/resources.
gooseTxCommit(tx1)
// Verify the primary record is indeed deleted.
count = goose(conn, "SELECT count() FROM primary_table WHERE id = $primaryId", {primaryId: primaryId})
assert(count == 0, "Record should be deleted")
// Verify the secondary record with the foreign key reference exists, an inconsistent state!
count = goose(conn, "SELECT count() FROM secondary_table WHERE primary_id = $primaryId", {primaryId: primaryId})
assert(count == 1, "Foreign key reference should exist")