Sentry Django ORM 模型设计实战指南:Silo 归属、复制、Relocation 与跨 Silo 外键的架构决策
Sentry 的后端是一个庞大的 Django 代码库,其中的每个 Model 都不仅仅是"一张表的映射",还隐含着一整套由单机多租户演进到"Cell / Control 双 Silo"与"Hybrid Cloud"后的架构约束:数据放哪个 Silo、是否要被另一个 Silo 看见、能否随组织一起被导出迁移、外键的删除语义由谁来保证。本文基于仓库中用于指导新模型设计的规范文档 django-models/SKILL.md,并结合 base.py、hybrid_cloud_foreign_key.py、scopes.py 等源码,完整讲解设计一个新 Django ORM 模型时需要依次拍板的四项决策、字段与约束层面的既定惯例,并给出一个开箱可用的 cell-silo 模型骨架。读完本文,你将能在 Sentry 代码库中独立设计、定位并落地一个符合全部架构约定的新模型。
这份指南的定位与使用边界
.agents/skills/django-models/SKILL.md 是一份面向"在 Sentry 中添加 Django ORM 模型"这一任务的架构决策手册。它的触发场景包括:为某个功能设计模型、决定某份新数据应存放在哪里、选择外键类型、重构已有模型的 Silo 归属,或者新建数据库表。它捕获的是建模时那些"改错一个就要连带迁移修一片"的架构性决策,因此不重复讲解 Django 语法、import 顺序或迁移生成——这些属于配套技能的职责:
- 迁移生成:模型设计完成后调用
generate-migration技能(见 generate-migration/SKILL.md); - Outbox 复制管道(signal receivers、payload 形状、删除处理器):见 hybrid-cloud-outboxes/SKILL.md;
- Hybrid Cloud RPC:见 hybrid-cloud-rpc/SKILL.md。
同时要注意该技能的范围:它只适用于 Django ORM 模型,不适用于 Pydantic 模型、dataclass、机器学习模型或 Protobuf。
定义 Sentry 模型的四项决策
Sentry 的建模规范强调:在写任何字段之前,先依次定下四个问题。这四者彼此耦合——任何一个判断失误,都会连带引发后续用于修正其他决策的迁移。
决策一:这份数据活在哪个 Silo?
Sentry 的运行模型把数据划分为两个 Silo:Cell Silo(承载单组织相关的业务数据)与 Control Silo(承载跨组织共享、或必须与其他 Control 资源强一致的数据,例如认证信息、集成安装、API Token、slug 预留)。
默认选择是 cell silo,即使用 @cell_silo_model 装饰器;只有当下述条件成立时才应改用 @control_silo_model:
- 数据在多个组织之间共享;或
- 数据必须与其它 control-silo 资源(auth、integration installs、API tokens、slug reservations)保持强一致。
源码中的两个装饰器分别绑定到 ModelSiloLimit(SiloMode.CELL) 与 ModelSiloLimit(SiloMode.CONTROL),定义见 base.py。ModelSiloLimit 通过替换模型的 objects manager 以及把 alters_data 方法替换为"不可用即抛 AvailabilityError"的守卫来实现运行时隔离:当服务运行在非该模型所属的 Silo 模式时,调用该模型的写入路径会直接失败。
规范的决策心法不是"这是面向用户的功能所以放 control",而是反过来问:"让这份数据正确存活所需的最小 Silo 是哪一个?它与所有会一起变更它的事物是否同处一个 Silo?" 如果某个 Cell 永远读不到这份数据,它就不属于该 Cell——正如装饰器 docstring 所言,cell 模型是"属于单个组织、或需要与其他 Region(Cell)资源强一致"的模型,而 control 模型是"被多个组织共享、或需要与其他 Control 资源强一致"的模型。
决策二:另一个 Silo 需要看到这份数据吗?
如果需要,模型就不能继承裸的 Model,而必须继承 ReplicatedCellModel(cell 侧)或 ReplicatedControlModel(control 侧),并声明:
category: ClassVar[OutboxCategory] = OutboxCategory.MY_THING
这两类复制基类定义于 outbox/base.py(ReplicatedCellModel,属 CellOutboxProducingModel)与同文件 L336-L347(ReplicatedControlModel)。它们各自要求声明一个 OutboxCategory,并且 base.py 中的 class_prepared 钩子会在类定义完成时自动把该 category 的更新事件接到模型上——也就是说,一旦模型继承复制基类并给出 category,写入时的 outbox 通知便被自动装配。
值得强调的是:复制是设计的一部分,而不是事后补上的胶水。"另一个 Silo 的 X 是否应该在不发 RPC 的情况下按 ID 查到这份数据"这个问题本身就是一个复制决策,它会直接改变模型的基类。裸 Model 只留给那些真正永远不会跨越 Silo 边界的数据。如果拿不准,应在最终确定模型前先咨询 hybrid-cloud-outboxes 技能。
决策三:这份数据是否属于组织导出(Relocation)的一部分?
每个具体模型都必须设置 __relocation_scope__——这不是建议而是硬性检查:base.py 的 class_prepared 信号处理器会遍历所有 BaseModel 子类,一旦发现缺少该属性立即抛出 ValueError,提示开发者填写该模型在导出/迁移流程中参与的范围。同处的检查还包含两条派生约束:
- 若
__relocation_scope__是一个set且其中含有Excluded,直接报错——Excluded永远只能作为独立值出现(L383-L390); - 凡
app_label == "getsentry"的模型必须为Excluded,否则报错——getsentry 中的东西不可能迁移到另一个实例(L392-L399)。
RelocationScope 枚举定义在 scopes.py,包含 Excluded、User、Organization、Config、Global 五个取值。规范文档把新模型的常见选择归结为下表:
| Scope | 适用时机 |
|---|---|
RelocationScope.Organization |
绑定组织的客户数据,应随组织一起在迁移中移动。覆盖大多数 project、设置、member、dashboard、alert。 |
RelocationScope.Excluded |
瞬时数据、遥测数据、缓存、系统内部状态、getsentry app 中的任何东西,或换一个实例后不再有意义的数据。 |
set 形式配合 get_relocation_scope() 覆写确实存在但很罕见,仅在 relocation 行为需要"按实例条件判断"时才值得使用(base.py 展示了 get_relocation_scope() 会转发 __relocation_scope__,部分模型按实例推导),且 Excluded 不能出现在集合中。
如果不确定,可遵循启发式规则:存储客户工作状态的新模型几乎都是 Organization;为 Sentry 自身运维而存在的新模型(队列、缓存、尝试记录、任务、运行时消费的 feature flag 等)几乎都是 Excluded。仓库中的实例可印证这一惯例,例如 workflow_engine/models/action.py 的 Action 与 action_alertruletriggeraction.py 都属于运行时执行相关数据,故二者均标为 RelocationScope.Excluded。
决策四:每个外键的跨 Silo 爆炸半径有多大?
外键类型在 Sentry 里是架构声明而非风格选择。规范明确区分三种情况:
FlexibleForeignKey("sentry.Project", on_delete=...):外键目标与当前模型同处一个 Silo。会创建真实的数据库约束,级联删除由 Postgres 强制执行。实现上它就是 DjangoForeignKey的子类(见 fields/foreignkey.py),只是把on_delete默认值设为models.CASCADE。由于保留了 ORM 关系,一次project = FlexibleForeignKey(...)声明即可同时得到project(关联对象)与project_id(列值)两个属性。HybridCloudForeignKey("sentry.User", on_delete="...", ...):外键目标位于对侧 Silo。实现位于 fields/hybrid_cloud_foreign_key.py,本质上是"一个带索引的 BigIntegerField":不产生任何数据库约束、不做强一致性保证,级联行为通过 outbox tombstone(CellTombstone/ControlTombstone)交给删除任务异步、最终一致地完成。因为不存在可解析的 ORM 关系,字段必须以显式_id后缀命名(如user_id = HybridCloudForeignKey(...)),且on_delete以字符串传入。可用取值与源码中的HybridCloudForeignKeyCascadeBehavior枚举一一对应(同文件 L60-L63):"CASCADE"、"SET_NULL"、"DO_NOTHING",传入的字符串会被upper()后解析,最终以大写形式落库。字段默认开启db_index=True,引用目标须写成"<app>.<ModelName>"两段式。- 原生 Django
ForeignKey:应当避免。从源码结构看,仅少数较老的workflow_engine模型仍在使用;新代码的统一约定是FlexibleForeignKey(同 Silo)或HybridCloudForeignKey(跨 Silo),因为二者都接入了 Sentry 的删除框架与 hybrid-cloud 管道,而裸ForeignKey没有这套集成。
一个模型同时存在两种外键完全正常且常见。 出现 HCFK 并不代表模型应该放到对侧 Silo——它只说明"这段关系跨越了 Silo 边界"。一个 cell 模型完全可以既用 FlexibleForeignKey 指向本 Silo 的 sentry.Organization,又用 HCFK 指向 control Silo 的 sentry.User。
其他值得固化的建模规范
基类与时间戳
新模型应使用 DefaultFieldsModel,它免费提供 date_added(auto_now_add=True)与 date_updated(auto_now=True),定义见 base.py。真正两个时间戳都不该记录的表极少,所以它"几乎总是你想要的"。而 DefaultFieldsModelExisting(base.py)是仅限遗留模型的基类——其 docstring 明确写着不要在新模型上使用,因为它把 date_added 留成可空以向后兼容早于该字段存在的表。另外,所有 SentryModel 的祖先 Model(base.py)统一使用 BoundedBigAutoField 作为 id 主键,并默认给出 __repr__ = sane_repr("id")。
字段类型的意图
- 主键用
BoundedBigAutoField;非主键的数字 ID 与计数用BoundedBigIntegerField/BoundedPositiveIntegerField。这些 bounded 系列字段都定义在 fields/bounded.py(BoundedPositiveIntegerField、BoundedAutoField、BoundedBigIntegerField、BoundedBigAutoField分别位于该文件约 L31/L68/L78/L108)。"bounded"是运行时溢出守卫而非 Django 的锦上添花——它能在数值越界悄然破坏下游消费方之前把问题拦截下来。 - 无严格规格的定长文本,倾向
CharField(max_length=256)。Postgres 在 TOAST 阈值以下,varchar(n)的存储成本不随 n 变化,因此过早选 64 只是"没有收益的约束"。只有列具备真实语义时才取更小的值(如 hash=40/64、UUID=32、slug、有规格的标识符)。 - 自由文本用
TextField。 - 新 JSON 列优先使用 Django 的
models.JSONField()(jsonb 存储)。sentry.db.models.fields.jsonfield里的旧版JSONField是文本存储,仅为兼容旧列而存在——只有当你刻意要匹配一个既有旧列时才用它。 - 可变的可调用默认值必须写成
default=dict、default=list,绝不写裸的default={}/default=[]。
软删除是显式的
Sentry 不存在基于 metaclass 的隐式软删除。若模型需要软删除,就显式添加一个使用 ObjectStatus 的 status 字段,并让业务逻辑在查询与写入时尊重它。仓库虽保留有 ParanoidModel(定义于 db/models/paranoia.py,sentry_app.py 中的 SentryApp 等即继承它),但它是一个重型选择——绝大多数新模型不需要走到这一步。
用 constraints 而非 unique_together
新模型应使用 Meta.constraints = [UniqueConstraint(...)] 替代 unique_together。理由在于 condition=Q(...):部分唯一约束(partial unique constraint)是表达"该列非空时才唯一"这类需求的唯一正确方式,而这种需求在业务中很常见,用 unique_together 表达会静默出错。约束名与索引名应当显式、有描述性,而不是依赖自动生成的名字。
组合索引要匹配查询顺序
如果总是以 (org_id, project_id, type) 一起过滤,就需要一个"字段顺序与先最高选择度字段的过滤模式一致"的组合索引。单靠外键自带的自动索引覆盖不了多列查询的情况——这也是决策四里 FlexibleForeignKey 只解决关联约束、不替你解决查询性能的原因。
模型文件放哪里
- 默认:放在
src/sentry/<app>/models.py(单文件)或src/sentry/<app>/models/<thing>.py(一模型一文件)——按该 app 既有先例选择; src/sentry/models/<thing>.py是遗留位置,只有在新模型与已住在那里的模型强耦合、挪动反而破坏性更大时,才把新模型放进去;- 位于
src/sentry/<app>/的新 app 应搭建成真正的 Django app(含apps.py,以及带default_app_config的__init__.py)。
一个最小可用的 cell-silo 模型骨架
以下是规范给出的起点脚手架——按需裁剪,不要塞入不需要的东西。它不是模板,而是提示"常规新模型长什么样":
from __future__ import annotations
from django.db import models
from sentry.backup.scopes import RelocationScope
from sentry.db.models import DefaultFieldsModel, FlexibleForeignKey, cell_silo_model, sane_repr
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
@cell_silo_model
class MyThing(DefaultFieldsModel):
__relocation_scope__ = RelocationScope.Organization
organization = FlexibleForeignKey("sentry.Organization")
project = FlexibleForeignKey("sentry.Project")
user_id = HybridCloudForeignKey("sentry.User", null=True, on_delete="SET_NULL")
name = models.CharField(max_length=256)
config = models.JSONField(default=dict)
class Meta:
app_label = "sentry"
db_table = "sentry_mything"
constraints = [
models.UniqueConstraint(
fields=["organization", "name"],
name="sentry_mything_org_name_unique",
),
]
__repr__ = sane_repr("organization_id", "project_id", "name")
逐行拆解这份骨架,可以看到四项决策与全部惯例是如何落地的:
- Silo:
@cell_silo_model把模型钉在 Cell Silo(决策一); - 外键语义:
organization、project是FlexibleForeignKey,同 Silo、真实 DB 约束、默认CASCADE;user_id是HybridCloudForeignKey,指向对侧 Silo 的sentry.User,删除行为取"SET_NULL"且显式允许null=True(决策四)。注意三者的写法差异:前两个能通过 ORM 直接拿到对象,而 HCFK 只有一个user_id列值; - Relocation:
__relocation_scope__ = RelocationScope.Organization(决策三)——该表存的是客户工作状态,应随组织一起迁移; - 字段类型:
name用CharField(max_length=256),config用 jsonb 的JSONField(default=dict)(可调用默认值); - 约束:唯一性通过
UniqueConstraint表达,并给了显式名字;db_table = "sentry_mything"遵循 Sentry 的命名惯例; - 可调试性:用
sane_repr(定义于 base.py)输出一串 ID 字段作为__repr__。
将其改成其它形态同样直接:
- 若要控制 Silo 模型,把
@cell_silo_model换成@control_silo_model; - 若这份状态需要被另一个 Silo 可见,把基类改为
ReplicatedCellModel/ReplicatedControlModel并补上category = OutboxCategory.MY_THING——其余复制管道的工作交给hybrid-cloud-outboxes技能。
模型设计完成之后
一个模型不是写完类就结束了。按规范文档,设计完成后的标准动作依次是:
- 生成迁移:调用
generate-migration技能; - 若模型需要复制:调用
hybrid-cloud-outboxes技能,接线 payload、signal receivers 与删除处理器; - 若所在 app 的
models/__init__.py习惯 re-export 模型:把新模型也加进去——这只是方便调用方写from sentry.<app>.models import Thing,并非 Django 硬性要求,跟随该 app 的先例即可。
HCFK 字段尤其要配合删除框架使用:其模块 docstring(hybrid_cloud_foreign_key.py)列出了接入 HCFK 前应完成的准备——确保目标模型在对侧 Silo、目标模型通过 outbox 在事务内原子地同步 tombstone、为模型在 sentry/deletions 注册合理的级联删除策略,并为新模型编写"父模型删除触发本字段预期级联行为"的模型测试。该 docstring 还特别提醒:把既有字段改成 HCFK 会产生一份默认不可用的迁移,通常需要 hybrid cloud 团队介入精修,所以不要轻率地对存量字段做这种变更。
结语
Sentry 的模型设计规范本质上是在回答一个核心问题:在一套分布式的双 Silo 架构里,一份新数据要"正确地活下去",必须在写第一行字段前就想清楚它住在哪、谁能看见它、它能否被带走、它的外键由谁来守护。把这四项决策连同字段类型、基类选择、约束与索引的惯例一并内化,再配合 generate-migration、hybrid-cloud-outboxes、hybrid-cloud-rpc 三个配套技能,就能在 Sentry 中产出一致、可维护、无需反复返工的 Django ORM 模型——这也是 django-models/SKILL.md 希望任何 Agent 或开发者遵循的建模路径。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00