索引类型
DuckDB 内置了两种索引类型。索引也可以通过扩展来定义。
最小-最大索引(Zonemap)
对于所有通用数据类型的列,系统都会自动创建最小-最大索引(也称为 zonemap 或块范围索引)。
自适应基数树(ART)
自适应基数树(Adaptive Radix Tree, ART)主要用于确保主键约束,并加速点查询以及高度选择性(即 < 0.1%)的查询。ART 索引可以使用 CREATE INDEX 子句手动创建,对于具有 UNIQUE 或 PRIMARY KEY 约束的列,系统会自动创建 ART 索引。
警告:目前在创建 ART 索引时,索引必须能够容纳在内存中。如果索引在创建过程中无法装入内存,请避免创建 ART 索引。
由扩展定义的索引
DuckDB 通过 spatial 扩展支持用于空间索引的 R 树。
持久性
最小-最大索引和 ART 索引都会持久化到磁盘上。
CREATE INDEX 和 DROP INDEX 语句
要创建 ART 索引,请使用 CREATE INDEX 语句。要删除 ART 索引,请使用 DROP INDEX 语句。
ART 索引的局限性
ART 索引会在第二个位置创建数据的副本。维护该副本会使处理过程复杂化,特别是在结合事务使用时。因此,在修改存储在二级索引中的数据时,目前存在一定的限制。
不出所料,索引对性能有显著影响,会减慢加载和更新速度,但能加速特定查询。请参阅性能指南以了解详细信息。
UPDATE 语句中的约束检查
对索引列和无法进行原地更新的列执行的 UPDATE 语句,会被转换为原始行的 DELETE 操作,随后再执行更新行的 INSERT 操作。这种重写会对性能产生影响,尤其是对于宽表,因为重写的是整行数据,而不仅仅是受影响的列。
此外,这还导致了 UPDATE 语句在约束检查方面的限制。这种限制在其他数据库管理系统(如 PostgreSQL)中也存在。
在下面的示例中,请注意行数是如何超过 DuckDB 默认 2048 的向量大小的。UPDATE 语句被重写为 DELETE,随后是 INSERT。此重写在 DuckDB 处理流水线中通过每个数据块(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 ... 语句中使用该结果,或者利用 DuckDB 的 Appender(如果客户端支持)。
外键中过分激进的约束检查
如果您满足以下条件,就会出现此限制:
- 表具有
FOREIGN KEY约束。 - 对相应
PRIMARY KEY表执行了UPDATE,DuckDB 将其重写为DELETE后跟INSERT。 - 待删除行存在于外键表中。
如果满足这些条件,您将遇到意外的约束冲突。
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
造成这种情况的原因是 DuckDB 尚不支持“前瞻”(looking ahead)。在 INSERT 过程中,它不知道作为 UPDATE 重写的一部分,它将重新插入外键值。
删除后与并发事务的约束检查
为了更好地理解索引的局限性,我们首先简要概述 DuckDB 中的索引存储。在定义基于索引的约束或使用 CREATE [UNIQUE] INDEX 语句时,DuckDB 会创建键列表达式及其行 ID 的物理二级副本。该二级结构位于其所属表的物理存储中。请注意,约束冲突仅与主键、外键和 UNIQUE 索引相关。
在运行事务时,DuckDB 只有在没有来自旧事务的依赖项时,才能更改或删除值。即,在所有仍需要查看旧值的旧事务完成后。DuckDB 使用 MVCC 来确保这种事务性。对于表存储,每个事务都知道它是否还能看到某个值。如果事务在更改/删除的 COMMIT 之前启动,则该事务可以看到该值。同样,如果它在之后启动,则它无法再看到该值。
索引尚不具备此类功能。 假设全局索引中存在一个 value-row_id 对,并且有一个更改/删除该值的 COMMIT。在这种情况下,它也对较新的事务保持可见,直到所有较旧的、相关的事务完成。这种行为导致了两个主要限制,详见下文。
另请注意,此限制延伸到对多个表的并发访问。如果表 X 和表 Y 位于同一架构中,对表 X 的旧读取事务可能会导致在对表 Y 进行连续更改时出现写-写冲突。
这些限制的长期解决方案是在索引中启用事务时间戳跟踪。然而,截至目前,DuckDB 尚不支持其索引的完全 MVCC。
变通方法
由于这是 DuckDB 的限制,目前没有纯 SQL 的变通方法。如果您在带有索引的表上有并发的读取和写入,则需要添加应用程序级的锁。即,如果并发读取正在运行时有多次写入发生,则这些写入必须等待读取完成。
您也可以考虑完全不使用索引。相反,DuckDB 的 MERGE INTO 语句可能更适合您的需求。
过分激进的唯一性约束检查
对于唯一性约束,插入操作可能会在应该成功时失败。
// Assume "someTable" is a table with an index enforcing uniqueness.
tx1 = duckdbTxStart()
someRecord = duckdb(tx1, "SELECT * FROM someTable USING SAMPLE 1 ROWS")
tx2 = duckdbTxStart()
duckdbDelete(tx2, someRecord)
duckdbTxCommit(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 = duckdbTxStart()
duckdbInsert(tx3, someRecord)
duckdbTxCommit(tx3)
// Following this, the above insert succeeds because the ART index was allowed to update.
duckdbTxCommit(tx1)
请注意,在旧版本的 DuckDB 中,某些变体可能看起来可以工作(没有约束异常)。对于 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
外键约束检查不足
对于外键约束,插入操作可能会在应该失败时成功。
// Setup: Create a primary table with a UUID primary key and a secondary table with a foreign key reference.
primaryId = generateNewGUID()
conn = duckdbConnectInMemory()
// Create tables and insert the initial record in the primary table.
duckdb(conn, "CREATE TABLE primary_table (id UUID PRIMARY KEY)")
duckdb(conn, "CREATE TABLE secondary_table (primary_id UUID, FOREIGN KEY (primary_id) REFERENCES primary_table(id))")
duckdbInsert(conn, "primary_table", {id: primaryId})
// Start a transaction tx1, which reads from primary_table.
tx1 = duckdbTxStart(conn)
readRecord = duckdb(tx1, "SELECT id FROM primary_table LIMIT 1")
// Note: tx1 remains open, holding locks/resources.
// Outside of tx1, delete the record from primary_table.
duckdbDelete(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.
duckdbInsert(conn, "secondary_table", {primary_id: primaryId})
// Commit tx1, releasing any locks/resources.
duckdbTxCommit(tx1)
// Verify the primary record is indeed deleted.
count = duckdb(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 = duckdb(conn, "SELECT count() FROM secondary_table WHERE primary_id = $primaryId", {primaryId: primaryId})
assert(count == 1, "Foreign key reference should exist")