深入解析 Sentry 异步删除子系统:从 ScheduledDeletion 调度到级联删除任务
【免费下载链接】sentryDeveloper-first error tracking and performance monitoring项目地址: https://gitcode.com/GitHub_Trending/sen/sentry
导读
当你在 Sentry 中删除一个组织、项目或 Issue(Group)时,涉及的数据并非只删一行那么简单——它们可能横跨数十张 PostgreSQL 关联表、事件存储(Eventstore/Nodestore)甚至外部服务。Sentry 在 src/sentry/deletions/README.md 中定义了一套完整的异步删除子系统(deletion subsystem):它通过 PostgreSQL 记录删除任务与状态,在后台按计划批量执行,并支持级联删除、失败重试与删除取消。读完本文,你将掌握该子系统的整体工作流、调度与重试机制、两大基础删除任务的区别、如何为新增模型接入自定义删除逻辑,以及如何直接通过 deletions 管理器同步执行删除。
子系统概览:为什么需要"删除子系统"
在 Sentry 的数据模型中,Organization → Project → Group(Issue)→ Event 层层嵌套,且大量业务模型(AlertRule、Rule、Monitor、SentryApp、Release 等)都挂靠在组织或项目之下。当应用新增一个模型时,就必须考虑:这条记录所属的组织或项目被删除后,它应当如何被清理?
删除子系统主要解决三个工程问题:
- 异步化与批量化:一次性同步删除海量关联行会长时间占用数据库连接并拖垮请求,因此删除被拆成可恢复的异步任务,逐 chunk 推进。
- 可重试与可靠性:删除任务可能因一次发布(deploy)被打断,或因新增外键关系、数据库故障而失败。子系统用 PostgreSQL 表跟踪每次删除的状态(是否 in_progress、计划时间等),从而支持失败后重新拾取。
- 级联策略可定制:不同模型的删除行为差异很大——有的需要逐条触发 Django signal(如依赖子关系清理的模型),有的可以单条 SQL 批量删除(无关联的叶子模型),有的还需要联动事件存储与外部分组服务(Seer)。
调度执行核心:Taskbroker 定时任务与重试机制
README 指出两个关键调度入口:
run_scheduled_deletions()每 15 分钟执行一次,它会查询所有"计划时间已到、且当前未被处理(不在 in_progress)"的删除任务,并为每一条任务派生(spawn)对应的删除处理任务。reattempt_deletions()每天执行一次,用来清理陈旧任务:它会清掉那些"卡住"的旧任务的in_progress标记,让它们能被下一次 15 分钟调度重新拾取。
对照当前仓库,这一节早已演化成Control / Cell 双轨实现。在 src/sentry/deletions/tasks/scheduled.py 中,实际存在一组配对任务:
run_scheduled_deletions_control(处理 Control 侧ScheduledDeletion)与run_scheduled_deletions(处理 Cell 侧CellScheduledDeletion);reattempt_deletions_control与reattempt_deletions同理,二者共用_reattempt_deletions()。
其中 scheduled.py 中_reattempt_deletions的实现细节是:只重置in_progress=True且date_scheduled早于当前时间 6 小时以上的任务——即"若删除进行中且计划时间已过去 6 小时,可认为上次任务已经死亡/失败",随后把in_progress翻转为False,使任务在下一个周期被重新拾取。
def _reattempt_deletions(model_class: type[BaseScheduledDeletion]) -> None: queryset = model_class.objects.filter( in_progress=True, date_scheduled__lte=timezone.now() - timedelta(hours=6) ) queryset.update(in_progress=False)而调度执行端_run_scheduled_deletions()(scheduled.py)则通过原子的条件更新来抢占任务,避免同一删除被多个 worker 重复执行:
queryset = model_class.objects.filter(in_progress=False, date_scheduled__lte=timezone.now()) for item in queryset: with transaction.atomic(router.db_for_write(model_class)): affected = model_class.objects.filter( id=item.id, in_progress=False, ).update(in_progress=True) if not affected: continue process_task.delay(deletion_id=item.id)即:只有成功把in_progress从False更新为True的进程才负责执行该删除,这正对应 README 中"查询过去到期、且未在进行中的任务"的描述。
实际的删除处理器run_deletion/run_deletion_control(scheduled.py)还配置了:
- 处理时限:
processing_deadline_duration分别为 15 分钟(control)与 20 分钟(cell); - 重试策略:最多重试 5 次(
MAX_RETRIES = 5),每次间隔 5 分钟,超过次数后丢弃任务;对DeleteAborted(删除被取消)异常不重试且静默处理。
任务主流程_run_deletion()的逻辑是:取出ScheduledDeletion→ 通过get_instance()还原真实对象 → 用 deletions 管理器拿到对应删除任务 → 调用task.should_proceed(instance)校验(不通过则直接删除调度记录并终止,这就是"可取消删除"的落地)→ 首轮发送pending_deletesignal → 反复调用task.chunk(),只要返回True就继续投递下一轮子任务,直到chunk()返回False(全部删完)才删除调度记录本身。
调度删除:ScheduledDeletion 模型
对绝大多数应用代码而言,进入删除子系统的入口是ScheduledDeletion模型——通过它创建一条"未来某个时间点执行"的删除任务。README 给出了最核心的用法:
from sentry.deletions.models.scheduleddeletion import ScheduledDeletion ScheduledDeletion.schedule(organization, days=1, hours=2)上面这行代码会把该 organization 调度为在1 天零 2 小时后被删除。从源码看,schedule() 是BaseScheduledDeletion上的类方法,其完整签名是:
@classmethod def schedule( cls, instance: Model, days: int = 30, hours: int = 0, data: Any = None, actor: Any = None ) -> Self:关键行为说明:
days默认30,hours默认0,即默认 30 天后执行;README 示例显式传参即可自定义更短的窗口。- 使用
update_or_create以(app_label, model_name, object_id)为唯一键,重复调度同一对象只会更新其计划时间,而不会产生重复任务。 actor会被记录到actor_id字段(用于删除审计);data存入 JSONField,可携带自定义上下文。- 该方法会校验模型的
silo_limit:若当前 Silo 模式下无法操作该模型,则直接抛出SiloLimit.AvailabilityError,防止在错误的 silo 中调度删除。
模型字段层面(scheduleddeletion.py),一次删除任务记录包含:
| 字段 | 含义 |
|---|---|
guid | 32 位 UUID hex,删除任务的唯一交易号(transaction_id) |
app_label/model_name | 被删除模型的 Django 定位信息 |
object_id | 被删除记录主键 |
date_added | 创建时间 |
date_scheduled | 计划执行时间(默认now() + 30 days) |
actor_id | 发起删除的用户(可空) |
data | JSON 扩展字段 |
in_progress | 是否正在处理中(调度与重试机制的核心开关) |
此外,当前仓库因多区域(silo)架构将这一模型拆成了两张物理表(scheduleddeletion.py):Control 侧的ScheduledDeletion(表名sentry_scheduleddeletion,由control_silo_model装饰)与 Cell 侧的CellScheduledDeletion(表名sentry_regionscheduleddeletion,由cell_silo_model装饰)。README 写作时以单一模型为例,接入时需按被删除模型所在 silo 选择对应的类——可以推断:monolith 模式下二者都会照常被处理。get_model()内部还会经过 RELOCATED_MODELS 映射,把历史上"模型应用已迁移"的旧任务(例如sentry.Monitor→monitors.Monitor)翻译到新的 app_label,保证旧调度任务仍能被正确还原与执行。
删除任务:两种内置基础策略
README 指出删除系统提供两个基类来覆盖常见场景:
ModelDeletionTask:逐条获取记录并分别删除每个实例。- 适合依赖 Django signals、或存在子关联的模型(例如删除一条
Group时逐条触发post_delete让下游联动)。 - 当某个模型没有显式注册删除任务时,它就是默认实现。
- 适合依赖 Django signals、或存在子关联的模型(例如删除一条
BulkModelDeletionTask:用单条查询批量删除记录。- 适合没有任何关联关系的"叶子"模型(例如
GroupAssignee、ProjectKey、EnvironmentProject等中间表),效率最高。
- 适合没有任何关联关系的"叶子"模型(例如
对照源码 base.py 可以看到二者在设计上的具体差异:
默认 chunk 大小不同。BaseDeletionTask.DEFAULT_CHUNK_SIZE = 100(base.py),而BulkModelDeletionTask将其重写为10000(base.py)——批删模型单轮可处理万行,逐条删除模型则以 100 为粒度精细推进。
chunk() 的行为不同。ModelDeletionTask.chunk()(base.py)在while循环中反复拉取query命中的记录并调用delete_bulk(),直到耗尽chunk_size配额:一旦某轮查不到更多行就返回False(已删完),否则返回True("还有更多工作",需要调度器再次投递)。BulkModelDeletionTask.chunk()(base.py)则直接调用bulk_delete_objects()(在unguarded_write与写库路由保护下执行原始批量 DELETE),同样以返回值指示是否还有剩余行。
delete_bulk() 的级联编排。BaseDeletionTask.delete_bulk()(base.py)先根据mark_in_progress决定是否把实例状态置为DELETION_IN_PROGRESS,再分别从get_child_relations_bulk()(批量视角)与每个实例的get_child_relations()(单实例视角)收集子关系,用_delete_children()递归调用对应子任务的chunk(),全部子关系清理完后再删除自身。
内置限速。ModelDeletionTask.chunk()中每轮都会调用_throttle_deletes()(base.py):若任务配置了rate_limit_option(指向一个整型 option 名称),系统会基于漏桶限速器(LeakyBucketRateLimiter,drip_rate 取 option 值、burst 取 max(rate, query_limit))按删除行数节流吞吐,避免海量删除压垮数据库。
为新增模型接入删除任务(自定义扩展指南)
当你的模型存在需要额外清理的子关联、或需要覆盖默认删除行为时,README 要求按以下两步注册自定义删除任务:
- 将删除任务子类加入
sentry.deletions.defaults - 在
sentry.deletions.__init__的默认管理器映射中注册该任务
当前仓库中,defaults目录(src/sentry/deletions/defaults/)已包含 40+ 个模型的删除任务定义(如organization.py、project.py、group.py、alertrule.py、monitor.py、rule.py、release.py等),并通过 defaults/init.py 统一导出。
而映射注册位于 src/sentry/deletions/init.py 的load_defaults():它调用manager.register(Model, TaskClass)把模型绑定到具体任务,例如:
manager.register(models.Group, defaults.GroupDeletionTask) manager.register(models.Organization, defaults.OrganizationDeletionTask) manager.register(models.Project, defaults.ProjectDeletionTask) manager.register(models.Activity, BulkModelDeletionTask)模块级还暴露了与 manager 一一对应的便捷函数(init.py):get()、register()、exec_sync()、exec_sync_many()。默认管理器由 get_manager() 以DeletionTaskManager(default_task=ModelDeletionTask)构建,并用functools.cache缓存——任何未显式注册的模型都会回退到ModelDeletionTask默认实现,这正是 README 所说"未指定删除任务时的默认策略"。
manager.py 中get()的解析逻辑也印证了这一点:self.tasks.get(model, self.default_task)——先查精确注册表,未命中则落到default_task。
实现子类时通常需要覆写的钩子
在 base.py 中,BaseDeletionTask预留了清晰的扩展点:
chunk():核心推进逻辑(一般继承ModelDeletionTask即可,无需重写);should_proceed(instance):根任务在执行前调用,用于支持删除被取消(详见下节);get_child_relations(instance)/get_child_relations_bulk(instance_list):返回该实例的子关联列表(元素为ModelRelation(model, query, task)或裸BaseRelation),是级联删除的"配方"来源;filter_relations():配合构造参数skip_models剔除不需要处理的子模型;mark_deletion_in_progress():默认把带status字段的实例批量更新为ObjectStatus.DELETION_IN_PROGRESS;- 构造参数
query、order_by、query_limit、chunk_size、actor_id、transaction_id等用于定制单个任务的查询范围与执行方式。
取消删除:should_proceed 钩子
如果某个记录已被调度删除、但希望之后能够取消,README 的指导是:让删除任务实现should_proceed钩子。典型实现为:
def should_proceed(self, instance: ModelT) -> bool: return instance.status in { ObjectStatus.PENDING_DELETION, ObjectStatus.DELETION_IN_PROGRESS }含义是:只有当记录当前状态仍属于"待删除/删除中"时才继续执行删除。仓库中最直接的落地例子是 defaults/organization.py 的OrganizationDeletionTask.should_proceed()——它只删除那些没有被撤销删除(undeleted)的组织:
class OrganizationDeletionTask(ModelDeletionTask[Organization]): def should_proceed(self, instance: Organization) -> bool: return instance.status in { OrganizationStatus.PENDING_DELETION, OrganizationStatus.DELETION_IN_PROGRESS, }其配套支持来自模型侧的 cancel():它会以(model_name, object_id)查找一条in_progress=False的调度记录并删除它。因此整个"可取消"闭环是:外部 API 先改变记录状态(例如恢复为正常状态)→ 定时任务到期执行should_proceed()发现状态不符 →调度记录被移除,删除不会发生。README 特别强调:当删除被该钩子取消时,对应的ScheduledDeletion行会被删除。
直接使用 Deletions 管理器(同步删除)
多数情况下删除走异步调度,但子系统同样支持在代码里同步驱动删除任务。README 以"删除一个 organization"为例给出了如下写法:
from sentry import deletions task = deletions.get(model=Organization, query={}) work = True while work: work = task.chunk()要点拆解:
deletions.get(model=..., query=...)会通过默认 manager 解析出该模型对应的删除任务并实例化(query为定位待删记录的过滤条件;exec_sync/exec_sync_many会把它封装成query={"id": instance.id}/{"id__in": [...]}的完整循环,见 manager.py);- 循环调用
task.chunk(),每次执行一个 chunk 的数据清理;返回值True表示仍有剩余工作,False表示实体已彻底移除,据此决定是否继续循环。
需要说明的是,query={}这种"全表/全量匹配"的写法在示例中意在展示接口形态;实际生产路径(如run_deletion)总是携带query={"id": deletion.object_id}精确定位单条记录(见 scheduled.py)。
级联行为取决于"对象类型"
README 强调系统针对 Organization 有默认实现,能够高效地级联删除——该行为会因输入对象不同而变化,因为任务可以为自己的子级覆写行为。两个典型对照:
删除 Group(Issue):传统逐级批删。GroupDeletionTask(defaults/group.py)以GROUP_CHUNK_SIZE = 100的粒度"准批量"推进:它会一次性为列表中的所有 group 组装子关系(涵盖GroupHash、GroupAssignee、Activity、UserReport、EventAttachment等DIRECT_GROUP_RELATED_MODELS+ADDITIONAL_GROUP_RELATED_MODELS中声明的模型),并按 Error / IssuePlatform 分类分别投递ErrorEventsDeletionTask与IssuePlatformEventsDeletionTask去清理事件存储中的 Event 数据。这符合 README"批处理每个子级(如 Event)"的描述——因为每一条 group 都有独立的事件负载需要异步清掉。
删除 Project:跳过 Group 任务、直接批量清理间接后代。而当删除一个 Project 时,并不会把事件交给已注册的Group任务逐组处理,而是采取更高效的路径:直接批量删除它的间接后代(如 Event)。从源码看,ProjectDeletionTask.get_child_relations()(defaults/project.py)把 20 余种子关联分门别类地列出来:像ProjectKey、GroupAssignee、EnvironmentProject这类模型直接挂BulkModelDeletionTask一次批量删掉;Group、Rule、Monitor、Activity等则走常规逐级任务。这样设计的原因是:既然整个 project 都要消失,就无需再为每个 Group 单独调度事件删除与级联——用更少的查询把其所有后代行成批清掉即可。
设计要点小结与扩展阅读
- 可靠性优先:所有删除任务落库为
ScheduledDeletion/CellScheduledDeletion记录,配合 15 分钟调度与每日重试清理,天然容忍发布中断与单次任务失败。 - 按数据形态选择策略:无关联的叶子数据用
BulkModelDeletionTask(默认 chunk 10000、单查询批删);依赖 signal/子关系的用ModelDeletionTask(默认 chunk 100);两者都以chunk()返回值作为"是否还有工作"的续跑信号。 - 删除可取消:通过
should_proceed()+ 对象状态(如PENDING_DELETION/DELETION_IN_PROGRESS)实现;取消时调度行会被清理,见ScheduledDeletion.cancel()。 - 模型扩展有纪律:新增模型接入只需两步(defaults 子类 + manager 注册);group 相关模型甚至由测试强制约束——defaults/group.py 的模块注释明确提醒:任何带
group_id外键的新模型必须登记到_GROUP_RELATED_MODELS列表中,否则 tests/sentry/deletions/test_validate_group_related_models.py 一类的校验测试会失败。
需要继续深入时,建议按以下路径阅读仓库源码:
- 整体说明文档:src/sentry/deletions/README.md
- 任务基类与限速实现:src/sentry/deletions/base.py
- 调度管理器与默认注册表:src/sentry/deletions/manager.py、src/sentry/deletions/init.py
- 调度模型与定时任务:src/sentry/deletions/models/scheduleddeletion.py、src/sentry/deletions/tasks/scheduled.py
- 三份代表性级联"配方":defaults/organization.py、defaults/project.py、defaults/group.py
【免费下载链接】sentryDeveloper-first error tracking and performance monitoring项目地址: https://gitcode.com/GitHub_Trending/sen/sentry
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考