SpacetimeDB 表约束(Constraints)全面解析:主键与唯一列的完整实践指南
2026/9/13 6:20:17 网站建设 项目流程

SpacetimeDB 表约束(Constraints)全面解析:主键与唯一列的完整实践指南

【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB

SpacetimeDB 通过表约束(Constraints)在数据库层强制数据完整性规则,支持**主键(Primary Key)唯一列(Unique Column)**两类约束。本文以官方核心概念文档 00240-constraints.md 为骨架,结合crates/bindings-macrocrates/bindingscrates/schemacrates/smoketests中的真实实现与测试,深入讲解主键与唯一约束在 Rust / TypeScript / C# / C++ 四种服务端语言中的定义方式、底层行为、更新语义与最佳实践。读完本文,你将能准确判断何时使用主键、何时使用唯一列,以及如何规避复合主键、重复约束冲突等常见陷阱。

约束体系概览:主键与唯一列在 SpacetimeDB 中的角色

在 SpacetimeDB 中,约束负责保证表内数据的唯一性与行的可寻址性:

  • 主键(Primary Key):唯一标识每一行,定义"行的身份",并直接决定更新(update)与删除(delete)的语义。
  • 唯一列(Unique Column):保证任意两行在该列上取值不同,服务于数据完整性。

无论定义哪种约束,SpacetimeDB 都始终维护集合语义(set semantics)——表中永远不会出现完全重复的行。二者的区别只在于"什么决定唯一性":是某一主键列,还是整行内容。

从源码角度看,约束与索引紧密绑定。在 crates/bindings-macro/src/table.rs 中,凡是带有#[unique]#[primary_key]注解的字段都会进入unique_columns列表,随后为每个"未被现有索引覆盖的唯一列"自动补建一个唯一索引(默认选用 btree 算法)。这意味着声明主键或唯一列的同时,也就隐式地创建了一个可用于高效查找的索引。

主键(Primary Key):定义行的唯一身份

主键唯一标识表中的每一行,代表行的身份(identity),并决定更新与删除的行为方式。以下分别给出 TypeScript、C#、Rust、C++ 四种服务端语言的定义方法。

四种语言的声明方式

TypeScript——在列构建器上调用.primaryKey()方法:

import { table, t } from 'spacetimedb/server'; const user = table( { name: 'user', public: true }, { id: t.u64().primaryKey(), name: t.string(), email: t.string(), } );

C#——使用[SpacetimeDB.PrimaryKey]特性标记字段:

[SpacetimeDB.Table(Accessor = "User", Public = true)] public partial struct User { [SpacetimeDB.PrimaryKey] public ulong Id; public string Name; public string Email; }

Rust——使用#[primary_key]属性标记字段:

#[spacetimedb::table(accessor = user, public)] pub struct User { #[primary_key] id: u64, name: String, email: String, }

C++——使用FIELD_PrimaryKey(table, field)宏在表注册之后标记主键:

struct User { uint64_t id; std::string name; std::string email; }; SPACETIMEDB_STRUCT(User, id, name, email) SPACETIMEDB_TABLE(User, user, Public) FIELD_PrimaryKey(user, id)

注:C++ 绑定以模块版本相关特性提供(CppModuleVersionNotice),使用前请确认你所使用的 SpacetimeDB C++ 绑定版本支持对应宏。

主键规则:三条硬性约束

  • 每表至多一个主键列:一张表最多只能有一个主键列。这一限制在宏展开与 schema 校验两个层面都做了强制——crates/bindings-macro/src/table.rs 中通过check_duplicate_msg报出 "can only have one primary key per table";而 crates/schema/src/def/validate/v9.rs 的validate_primary_key会在主键列数超过 1 时直接返回ValidationError::RepeatedPrimaryKey
  • 身份不可变(Immutable identity):主键定义了行的身份。修改主键值会被视为先删除旧行、再插入新行,而非原地的字段修改。
  • 天然唯一:主键自动具备唯一性,任何两行不能拥有相同的主键值。

由于唯一性要求,SpacetimeDB 使用**唯一索引(unique index)**来实现主键,该索引在表定义时自动创建,无需手动维护。

底层实现验证:从属性到唯一索引的完整链路

从源码可以看到主键声明被翻译为底层约束的完整流程:

  1. 宏解析阶段(crates/bindings-macro/src/table.rs):ColumnAttr枚举识别#[primary_key]#[unique]#[auto_inc]#[index(...)]#[default(...)]等字段级属性。
  2. 约束登记阶段(crates/bindings/src/rt.rs):register_table依次调用with_unique_constraint(col)注册唯一约束、with_index(...)注册索引、with_primary_key(primary_key)注册主键、with_column_sequence(col)注册自增序列。
  3. Schema 校验阶段(crates/schema/src/def/validate/v9.rs):校验主键列必须被一条唯一约束覆盖,否则抛出MissingPrimaryKeyUniqueConstraint

主键的更新语义:原地修改 vs 删除重建

当更新一行时,SpacetimeDB 依据主键判断这是"修改"还是"替换":

  • 主键相同:行被原地更新,订阅方(subscriber)收到一个update 事件
  • 主键不同:旧行被删除、新行被插入,订阅方依次收到delete 事件 + insert 事件

四种语言中均通过"按主键查找 → 修改非主键字段 → 更新"的流程实现:

TypeScript

export const updateUserName = spacetimedb.reducer({ id: t.u64(), newName: t.string() }, (ctx, { id, newName }) => { const user = ctx.db.user.id.find(id); if (user) { // This is an update — primary key (id) stays the same ctx.db.user.id.update({ ...user, name: newName }); } });

C#

[SpacetimeDB.Reducer] public static void UpdateUserName(ReducerContext ctx, ulong id, string newName) { var user = ctx.Db.User.Id.Find(id); if (user != null) { // This is an update — primary key (Id) stays the same user.Name = newName; ctx.Db.User.Id.Update(user); } }

Rust

#[spacetimedb::reducer] fn update_user_name(ctx: &ReducerContext, id: u64, new_name: String) -> Result<(), String> { if let Some(mut user) = ctx.db.user().id().find(id) { // This is an update — primary key (id) stays the same user.name = new_name; ctx.db.user().id().update(user); } Ok(()) }

C++

SPACETIMEDB_REDUCER(update_user_name, ReducerContext ctx, uint64_t id, std::string new_name) { auto user_opt = ctx.db[user_id].find(id); if (user_opt.has_value()) { User user_update = user_opt.value(); user_update.name = new_name; ctx.db[user_id].update(user_update); } return Ok(); }

关于update的语义边界,crates/bindings/src/table.rs 中的UniqueColumn::update给出了更精确的约束:它只允许在主键列上调用(通过PrimaryKeymarker trait 限制,见 crates/bindings/src/table.rs),且要求目标行必须已存在,否则 panic;若希望在非主键唯一列上执行"覆盖写入",需要先.delete(key).insert(row)。这从 API 设计层面防止了"什么是更新、什么是删除+插入"的歧义。

复合主键(Composite Primary Key):暂不支持与推荐替代方案

SpacetimeDB目前不支持多列(复合)主键。如果你需要按多个列的组合来定位行,官方推荐的做法是:使用一个自增(auto-increment)主键 + 一个多列 btree 索引

TypeScript

const inventory = table( { name: 'inventory', public: true, indexes: [ { accessor: 'byUserItem', algorithm: 'btree', columns: ['userId', 'itemId'] }, ], }, { id: t.u64().primaryKey().autoInc(), userId: t.u64(), itemId: t.u64(), quantity: t.u32(), } );

C#

[SpacetimeDB.Table(Accessor = "Inventory", Public = true)] [SpacetimeDB.Index.BTree(Accessor = "ByUserItem", Columns = new[] { nameof(UserId), nameof(ItemId) })] public partial struct Inventory { [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc] public ulong Id; public ulong UserId; public ulong ItemId; public uint Quantity; }

Rust

#[spacetimedb::table(accessor = inventory, public, index(accessor = inventory_index, btree(columns = [user_id, item_id])))] pub struct Inventory { #[primary_key] #[auto_inc] id: u64, user_id: u64, item_id: u64, quantity: u32, }

C++

struct Inventory { uint64_t id; uint64_t user_id; uint64_t item_id; uint32_t quantity; }; SPACETIMEDB_STRUCT(Inventory, id, user_id, item_id, quantity) SPACETIMEDB_TABLE(Inventory, inventory, Public) FIELD_PrimaryKeyAutoInc(inventory, id) // Named multi-column btree index on (user_id, item_id) FIELD_NamedMultiColumnIndex(inventory, by_user_item, user_id, item_id)

这样既可以用简单的自增值作为主键,又能通过多列 btree 索引对(userId, itemId)组合做高效查找。

无主键的表:整行即身份

并非必须声明主键。没有主键时,整行内容充当主键

  • 行由其完整内容唯一标识;
  • 更新操作需要匹配全部字段;
  • 重复行不可能出现——插入一条与已有行完全相同的记录不会产生任何效果(幂等)。

也就是说,无论是否定义主键,SpacetimeDB 始终维持集合语义;差异只在于"唯一性的判定依据"是主键列还是整行。由于主键会带来索引开销,如果某张表只被全表迭代访问(不按键查找),省略主键反而可能获得更好的性能。

从 crates/schema/src/def/validate/v9.rs 可以看到,primary_key: None是表定义的合法初始状态,进一步印证了主键是可选声明。

常见主键模式

模式一:自增主键(Auto-incrementing IDs)

primaryKey()autoInc()组合,为每条新记录自动分配唯一 ID:

#[spacetimedb::table(accessor = post, public)] pub struct Post { #[primary_key] #[auto_inc] id: u64, title: String, content: String, }

自增列的触发规则与可用类型详见同目录文档 00230-auto-increment.md:向自增列写入0时触发序列分配,写入非零值时则直接使用该值;自增列必须是整型(Rust 侧为i8/u8/i16/u16/i32/u32/i64/u64/i128/u128)。schema 校验层(crates/schema/src/def/validate/v9.rs)还会强制自增列类型为整型,并保证同一列只能注册一个序列(OneAutoInc错误)。

模式二:身份(Identity)作为主键

对用户相关的数据,直接用调用者的Identity作为主键:

#[spacetimedb::table(accessor = user_profile, public)] pub struct UserProfile { #[primary_key] identity: Identity, display_name: String, bio: String, }

该模式保证每个身份只能有一条资料,同时让"按身份查找"变得高效。

唯一列(Unique Columns):数据完整性约束

将列标记为 unique 可以保证任意两行在该列上的值互不相同。与主键不同,一张表可以拥有多个唯一列;同样地,唯一列也会隐式创建索引,从而支持高效查找。

四种语言的声明方式

TypeScript——使用.unique()方法:

const user = table( { name: 'user', public: true }, { id: t.u32().primaryKey(), email: t.string().unique(), username: t.string().unique(), } );

C#——使用[SpacetimeDB.Unique]特性:

[SpacetimeDB.Table(Accessor = "User", Public = true)] public partial struct User { [SpacetimeDB.PrimaryKey] public uint Id; [SpacetimeDB.Unique] public string Email; [SpacetimeDB.Unique] public string Username; }

Rust——使用#[unique]属性:

#[spacetimedb::table(accessor = user, public)] pub struct User { #[primary_key] id: u32, #[unique] email: String, #[unique] username: String, }

C++——使用FIELD_Unique(table, field)宏:

struct User { uint32_t id; std::string email; std::string username; }; SPACETIMEDB_STRUCT(User, id, email, username) SPACETIMEDB_TABLE(User, user, Public) FIELD_PrimaryKey(user, id) FIELD_Unique(user, email) FIELD_Unique(user, username)

运行时行为:查找、删除与约束冲突

每个#[unique]#[primary_key]列在运行时都会生成一个UniqueColumn句柄(在 C# 中体现为ctx.Db.Table.Field形式的强类型访问器),支持finddeleteupdate等操作(见 crates/bindings/src/table.rs)。插入违反唯一约束的行会失败并抛出约束冲突:

  • crates/smoketests/tests/cluster/filtering.rs 的测试验证了"向带#[unique] id#[unique] nick的表重复插入相同(id, nick)组合"会产生UNIQUE CONSTRAINT VIOLATION ERROR
  • crates/smoketests/tests/cluster/auto_inc.rs 的test_autoinc_unique遍历 10 种整型,验证了重复插入自增唯一列冲突时add_new_*reducer 返回错误,其测试模块源码见 crates/smoketests/modules/autoinc-unique/src/lib.rs,其中表Person_*同时声明了#[auto_inc] #[unique] key_col#[unique] name

与索引约束相关的校验规则

唯一约束与索引之间存在强绑定,schema 校验层(crates/schema/src/def/validate/v9.rs)要求:

  • 唯一约束必须有对应索引支撑,否则报UniqueConstraintWithoutIndex
  • 单列direct索引必须#[unique]约束成对出现(见 crates/bindings-macro/src/table.rs 的编译期校验:a direct index must be paired with a #[unique] constraint)。

主键 vs 唯一列:一张表看清取舍

两者都强制唯一性,但用途截然不同。官方文档的对比表如下:

AspectPrimary KeyUnique Column
Purpose(用途)Row identity(行的身份)Data integrity(数据完整性)
Count per table(每表数量)One(一个)Multiple allowed(允许多个)
Update behavior(更新行为)Delete + InsertIn-place update(原地更新)
Required(是否必需)NoNo

实际选型建议:

  • 需要"定位并修改某一行"时,用主键——它是更新语义的锚点;
  • 只需要"防止重复值"(如邮箱、用户名唯一)时,用唯一列——它不承载行身份语义;
  • 多个字段都需要唯一时,主键只能选一个,其余用唯一列;
  • 唯一列的更新不会触发 delete+insert 事件流,订阅端看到的是 update 事件;而主键值一旦改变,订阅端会看到 delete+insert 两个事件。

小结:约束设计的关键决策点

  1. 身份 vs 完整性:主键回答"这一行是谁",唯一列回答"这个值不能重复";一张表只有一列能成为"身份"。
  2. 复合主键的替代:SpacetimeDB 暂不支持复合主键,请用"自增主键 + 多列 btree 索引"组合达成等价能力。
  3. 无主键表:仅当表只做全表扫描且不需要按键更新时考虑省略主键,以省去索引开销;但要注意整行唯一、更新需匹配全字段。
  4. 事件语义:主键决定更新是 in-place 还是 delete+insert,直接影响客户端订阅事件的形态。
  5. 约束冲突:唯一约束在插入时即时生效,冲突会以 reducer 错误形式返回;对于需要"存在则更新"的场景,可考虑insert_or_update类 upsert 语义(见 crates/bindings/src/table.rs 的 unstable API)。

这些规则在宏层(crates/bindings-macro/src/table.rs)、绑定层(crates/bindings/src/table.rs、crates/bindings/src/rt.rs)与 schema 校验层(crates/schema/src/def/validate/v9.rs)被层层强制,理解这条链路有助于你在编写模块时预判编译期与运行期的各类约束相关错误。

【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询