Django-Signals
这篇讲什么
- Signals 是 Django 内置的观察者模式:某个动作发生时把消息广播给所有注册过的接收器,两边不用互相 import。
- 它是同步调用,所以定位是「解耦的钩子」而不是异步任务队列。这篇把内置信号清单(本机实测枚举)、
connect的四个参数、dispatch_uid与weak=True的真实行为、注册位置、哪些写法不会发信号、多对多变化的六种 action、接收器抛异常的后果和自定义信号都跑了一遍。基线 Django 5.2.19。
一句话先说清楚
- Signals 是 Django 内置的观察者模式:某个动作发生时(保存、删除、多对多变化、请求进来),发送方把消息广播给所有注册过的接收器,两边不用互相 import。
- 它是同步调用。接收器就在当前进程、当前这一次
save()里跑完才返回。实测一个time.sleep(0.5)的接收器让save()花了 0.51 秒。 - 所以它的定位是「解耦的钩子」,不是「异步任务队列」。真要异步请交给 Celery(见《Django-Celery》),信号最常见的用法就是那根「触发 Celery 的胶水」。
什么时候该用,什么时候不该用
- 适合:一处变化要多处响应,而且这几处互相没有依赖。
- 记录操作日志、写审计表。
- 数据变化后清缓存 / 预热缓存。
- 关联业务通知:员工入职 → 发消息通知团队;离职 → 清理权限。
- 把「主流程」和「附加动作」拆开,让
models.py不必知道谁在监听。
- 不适合:
- 耗时动作(发钉钉、调第三方接口)直接写在接收器里 —— 它是同步的,用户就在屏幕前等这 0.5 秒。正确做法是在接收器里
transaction.on_commit()+ 投 Celery 任务。 - 强一致的业务规则 —— 接收器抛异常会把
save()一起带崩(下面有实测),而且bulk_create()、queryset.update()根本不触发信号。约束请写进数据库约束或Model.clean()。 - 靠信号做「谁先谁后」的流程编排 —— 接收器的调用顺序只保证「后注册的先调用」,别把它当工作流引擎。
- 耗时动作(发钉钉、调第三方接口)直接写在接收器里 —— 它是同步的,用户就在屏幕前等这 0.5 秒。正确做法是在接收器里
内置信号清单(本机 Django 5.2.19 实测枚举)
-
把每个模块
dir()一遍,凡是django.dispatch.Signal实例的都列出来:1
2
3
4
5
6
7
8
9
10import importlib
from django.dispatch import Signal
for name in ["django.core.signals", "django.db.models.signals",
"django.contrib.auth.signals", "django.db.backends.signals",
"django.test.signals", "django.utils.autoreload"]:
mod = importlib.import_module(name)
found = [n for n in dir(mod) if isinstance(getattr(mod, n), Signal)]
print("%-32s %s" % (name, found))1
2
3
4
5
6
7django.core.signals ['got_request_exception', 'request_finished', 'request_started', 'setting_changed']
django.db.models.signals ['class_prepared', 'm2m_changed', 'post_delete', 'post_init', 'post_migrate',
'post_save', 'pre_delete', 'pre_init', 'pre_migrate', 'pre_save']
django.contrib.auth.signals ['user_logged_in', 'user_logged_out', 'user_login_failed']
django.db.backends.signals ['connection_created']
django.test.signals ['setting_changed', 'template_rendered']
django.utils.autoreload ['autoreload_started', 'file_changed'] -
常用的这几个记这几条就够(旧笔记只列了 9 条,这里把「什么时候发」和「接收器能拿到什么」补齐):
信号 定义位置 什么时候发 接收器额外拿到的参数(实测) pre_initdjango.db.models.signalsModel(...)还没执行__init__体args、kwargspost_init同上 Model(...)初始化完;从库里读出来也会发只有 instance(此时pk可能已有值)pre_save同上 save()里 SQL 执行前update_fields(frozenset或None)、rawpost_save同上 save()里 SQL 执行后created(True 新建 / False 更新)、usingpre_delete同上 delete()里 SQL 执行前using、origin;此时对象还在库里post_delete同上 delete()里 SQL 执行后同上;此时对象已经没了 m2m_changed同上 多对多的 add/remove/clearaction、reverse、model、pk_set、usingpre_migrate/post_migrate同上 migrate前后建表后才能用的接收器挂这里 request_starteddjango.core.signals请求进来、还没进中间件 WSGI 下是 environ,ASGI 下是scoperequest_finished同上 响应生成完 没有额外参数 got_request_exception同上 视图抛异常(500 前) requestsetting_changed同上 override_settings进入/退出时setting、value、enteruser_logged_in/user_logged_outdjango.contrib.auth.signals登录、登出成功 user、requestuser_login_failed同上 认证失败 credentials(密码已被替换成**)、requestconnection_createddjango.db.backends.signals新建一条数据库连接 connectiontemplate_rendereddjango.test.signals模板渲染完(要挂 InstrumentedTemplateRenderer)template、context -
两个容易记错的点,都是实测:
request_started/request_finished/got_request_exception在django.core.signals,不在django.db.models.signals。- 缓存没有「命中/未命中」信号:
django.core.cache.signals这个模块在本机 5.2.19 上直接ModuleNotFoundError。想统计命中率要在自定义缓存后端或中间件里做。
注册接收器:connect 的四个参数
-
所有信号都是
django.dispatch.Signal的实例,注册就是调它的connect:1
Signal.connect(receiver, sender=None, weak=True, dispatch_uid=None)
参数 类型 默认 说明 receiver可调用对象 必填 处理信号的函数,签名必须能接住 **kwargs,否则DEBUG=True时connect()当场报ValueError: Signal receivers must accept keyword arguments (**kwargs).sender任意对象 None只接收这个发送方的信号; None表示谁都收(这时你必须在接收器里自己判断instance类型)weakbool True弱引用接收器。默认 True的意义见下一节dispatch_uid可哈希(一般字符串) None给接收器一个唯一身份,用来防止重复注册 -
sender还能写字符串(模型路径),这是@receiver才用得上的写法,好处是接收器模块不必 import 模型:1
# 等价于 sender=Resume
实测这样挂上去照样生效,接收器里的
sender拿到的还是真正的类对象(打印出来是sender=Resume created=True)。
dispatch_uid 到底解决什么问题
-
旧笔记写的是「用来避免接收器被重复注册」,方向对但不够准。实测三种情形:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28hits = {"a": 0, "b": 0, "c": 0}
def make_receiver(bucket):
def handler(sender, **kwargs):
hits[bucket] += 1
return handler
# 情形 1:每次新建一个闭包,不带 uid
for _ in range(3):
post_save.connect(make_receiver("a"), sender=AuditLog)
AuditLog.objects.create(action="C-无uid", detail="")
print(hits["a"]) # 3
# 情形 2:同样三次,但都带同一个 dispatch_uid
for _ in range(3):
post_save.connect(make_receiver("b"), sender=AuditLog, dispatch_uid="same_uid")
AuditLog.objects.create(action="C-有uid", detail="")
print(hits["b"]) # 1
# 情形 3:同一个函数对象 connect 两次
same = make_receiver("c")
post_save.connect(same, sender=AuditLog)
post_save.connect(same, sender=AuditLog)
AuditLog.objects.create(action="C-同一函数", detail="")
print(hits["c"]) # 11
2
33
1
1 -
结论(对着
django/dispatch/dispatcher.py的实现读):- 不带
dispatch_uid时,去重键是(id(receiver), id(sender))。同一个函数对象重复connect本来就不会重复注册(情形 3)。 - 但每次新造的闭包 / lambda / 绑定方法都是不同的对象,
id不同,于是注册三遍就收三次(情形 1)。 dispatch_uid把去重键换成你给的字符串,于是「每次新建的接收器」也能只留一个(情形 2)。
- 不带
-
所以真正需要它的是这两种场景:接收器是闭包/lambda,或者同一个模块可能被重复 import(下一节的
reload)。给接收器起个全局唯一的dispatch_uid,是几乎零成本的保险。
weak=True 的真实行为
-
weak=True(默认)时,Django 存的是接收器的弱引用:接收器一旦被垃圾回收,注册自动失效。 -
危险的不是普通函数(模块级函数被模块持有,永远活着),而是绑定方法和局部函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class Notifier:
def __init__(self):
self.n = 0
def handle(self, sender, **kwargs):
self.n += 1
print("绑定方法收到一次(累计 %s)" % self.n)
nt = Notifier()
post_save.connect(nt.handle, sender=AuditLog)
AuditLog.objects.create(action="D-方法还在", detail="") # 打印 累计 1
del nt
gc.collect()
AuditLog.objects.create(action="D-对象没了", detail="") # 什么都不打印1
绑定方法收到一次(累计 1)
- 实测
del nt之后weakref.ref(nt)()已经是None,第二条create()再也不会触发那个接收器 —— 这就是「明明注册过却不执行」的经典原因。 - 对策二选一:把接收器写成模块级函数(推荐),或者
connect(..., weak=False)。 weak=False实测确实一直收得到(实例删了照样收到),代价是这个对象永远不会被回收,热重载也换不掉它。
- 实测
在哪注册:AppConfig.ready()
-
官方推荐把接收器单独放一个模块,然后在应用的
ready()里 import。原因是ready()保证在所有应用加载完、数据库表可用之后执行且只执行一次。 -
按官方推荐这么放就行:
jobs/apps.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15import logging
from django.apps import AppConfig
logger = logging.getLogger(__name__)
class JobConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "jobs"
verbose_name = "招聘"
def ready(self):
logger.info("JobConfig.ready() 开始加载信号接收器")
import jobs.signals # noqa: F401settings.py 1
2
3
4
5
6
7
8
9
10# settings.py:注册 AppConfig 本身,不要只写应用名
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"jobs.apps.JobConfig",
]- 一个应用只能出现一次:
"jobs"和"jobs.apps.JobConfig"同时写会报ImproperlyConfigured: Application labels aren't unique, duplicates: jobs。 - 只写
"jobs"时,Django 会自动找jobs/apps.py里default = True的那个AppConfig(Django 3.2 起的行为),所以两种写法都能跑,写全名更直白。
- 一个应用只能出现一次:
-
为什么强调「只 import 一次」:
@receiver是模块顶层语句,模块被重复执行就会重复注册。实测把jobs.signals重新加载一次:1
2
3
4
5import importlib
pre_save._live_receivers(Resume) # 加载前:1 个
importlib.reload(jobs.signals)
pre_save._live_receivers(Resume) # 加载后:2 个1
2
3对照:pre_save(Resume) 的接收器 reload 前=1(on_pre_save 没写 dispatch_uid)
reload 之后=2 —— 没有 dispatch_uid 的接收器会翻倍
post_save(Resume) 因为写了 dispatch_uid,reload 前后都是 1同一条
post_save因为带了dispatch_uid="resume_post_save",数量纹丝不动。这就是那一行dispatch_uid的价值。
两种注册写法
-
装饰器写法(推荐,声明和注册贴在一起):
jobs/signals.py 1
2
3
4
5
6
7
8
9
10
11from django.db.models.signals import post_save
from django.dispatch import receiver
from jobs.models import Resume
def on_resume_saved(sender, instance, created, **kwargs):
message = "简历已保存:%s(%s)" % (
instance.username, "新建" if created else "更新")
print(message) -
手工写法(适合「同一个函数要挂到多个信号/多个 sender」):
1
2post_delete.connect(on_resume_deleted, sender=Resume,
dispatch_uid="resume_post_delete") -
@receiver还支持一次挂多个信号,实测这样写没问题:1
2
3
def on_resume_changed(sender, instance, **kwargs):
... -
接收器的调用顺序:同一个 sender 上,后注册的先被调用(
send()遍历前会把列表反转)。别依赖这个顺序写业务,需要顺序就合并成一个接收器。
保存与删除:信号里到底给了什么
-
示例模型(
jobs/models.py):models.py 1
2
3
4
5
6
7
8
9class Job(models.Model):
job_name = models.CharField("岗位名", max_length=50)
tags = models.ManyToManyField("jobs.Tag", verbose_name="标签", blank=True)
class Resume(models.Model):
username = models.CharField("候选人", max_length=30)
apply_position = models.CharField("投递岗位", max_length=50)
job = models.ForeignKey(Job, on_delete=models.PROTECT, related_name="resumes") -
新建一条简历,四个信号依次收到(实测输出,
pre_save里我顺手strip()了字段):1
2
3
4
5pre_init {'args': (), 'kwargs': {}}
post_init {'pk': None}
pre_save {'pk': None, 'update_fields': None, 'raw': False}
post_save {'created': True, 'sender': 'Resume', 'using': 'default', 'pk': 4}
库里 apply_position='后端开发'(pre_save 里 strip 过了)pre_init的args/kwargs就是你调用Resume(...)时传的那份参数,此时对象还没初始化。post_init在Resume(...)和从数据库读出来时都会发,实测Resume.objects.get(pk=...)和list(Resume.objects.all())各触发一次pre_init+post_init。所以别在post_init里做「每次新建都要干一次」的副作用,它比你想象的更常跑。pre_save里改instance的字段会真的落库(上面那行strip()就是证据)。想统一清洗数据,这里是钩子。raw是loaddata之类的原始加载标志,正常业务代码里恒为False。
-
更新时带了
update_fields:1
2resume.apply_position = "前端开发"
resume.save(update_fields=["apply_position"])1
2pre_save {'update_fields': frozenset({'apply_position'}), 'raw': False}
post_save {'created': False, ...}- 注意
post_save的created是False,接收器必须两种情况都处理。 - 看到
update_fields不是None时,别在接收器里读其它字段的「数据库真值」——你手上这个instance只有被更新的那几列是新值。
- 注意
-
删除时两个信号的区别,实测就是「对象还在不在库里」:
1
2pre_delete {'sender': 'Resume', 'pk': 9, 'using': 'default', '还在库里': True}
post_delete {'sender': 'Resume', 'pk': 9, 'using': 'default', '还在库里': False}- 想留快照(比如把删掉的记录写进回收站表),必须在
pre_delete里做。 - 两个信号还额外收到
signal(就是信号对象本身)和origin(谁发起的删除,用于区分级联来源)。
- 想留快照(比如把删掉的记录写进回收站表),必须在
哪些写法「不会」发信号
-
这是新手最容易踩的一类:代码看起来执行了,接收器却一次都没跑。实测:
1
2
3
4
5
6
7
8
9
10
11Resume.objects.bulk_create([
Resume(username="李四", apply_position="算法", job=job),
Resume(username="王五", apply_position="算法", job=job),
])
# post_save 收到 0 次
Resume.objects.filter(pk=r.pk).update(apply_position="数据开发")
# 事件列表是空的:update() 直接发 UPDATE SQL,不实例化对象,也不发信号
Resume.objects.filter(pk=r.pk).delete()
# ['pre_init', 'post_init', 'pre_delete', 'post_delete']写法 发不发信号 说明 Model.objects.create()/instance.save()发 pre_save+post_save正常路径 Model.objects.bulk_create([...])都不发 官方文档明确写了不发送 save()信号queryset.update(...)都不发 只发一条 UPDATE SQL instance.delete()发 pre_delete+post_delete正常路径 queryset.delete()会发(每个对象一对) 它先把对象查出来(所以连 pre_init/post_init都发了),再逐个删ForeignKey赋值resume.job = job不发 只有真正 save()才发;多对多才有m2m_changed -
于是有两条实用规则:
- 需要「每条数据都被处理」的逻辑,别指望
bulk_create;要么循环save()(慢),要么在批量写入后手工补一次处理。 - 反过来,做数据迁移/初始化时如果接收器会捣乱(比如写审计表把数据库撑爆),就在迁移里用
raw=True判断,或者临时disconnect。
- 需要「每条数据都被处理」的逻辑,别指望
m2m_changed:六个 action 与正反两个方向
-
接收器一般挂在中间表上,这样才拿得到
pk_set:1
2
3
def on_tags_changed(sender, action, reverse, model, pk_set, instance, **kwargs):
print(action, reverse, model.__name__, pk_set, type(instance).__name__) -
实测三个动作各发一对(前置 + 后置):
1
2
3job.tags.add(t1, t2) -> [('pre_add', [6, 7], False), ('post_add', [6, 7], False)]
job.tags.remove(t1) -> [('pre_remove', [6]), ('post_remove', [6])]
job.tags.clear() -> [('pre_clear', None), ('post_clear', None)]参数 含义 actionpre_add/post_add/pre_remove/post_remove/pre_clear/post_clearreverse从哪一侧发起。 False= 从定义ManyToManyField的一侧(job.tags);True= 反方向(tag.job_set)model对侧的模型类。正向时是 Tag,反向时是Jobpk_set对侧被改的那些主键集合; clear时是Noneinstance本侧的对象。正向是那个 Job,反向是那个Tag -
反方向实测(
t2.job_set.add(job)):1
{'action': 'pre_add', 'reverse': True, 'model': 'Job', 'pk_set': [16], 'instance': 'Tag(9)'}
- 所以接收器里不能直接写
instance.job_name——反向时instance是Tag,会当场AttributeError: 'Tag' object has no attribute 'job_name'(我踩过了)。稳妥写法是先看reverse或用isinstance(instance, Job)分流。 pre_*里改pk_set可以影响最终写入的关系(pre_clear时抛异常还能阻止清空)。
- 所以接收器里不能直接写
接收器抛异常会怎样
-
把会炸的函数挂到
post_save上,实测:1
2
3
4
5
6def broken_receiver(sender, **kwargs):
raise RuntimeError("接收器炸了")
post_save.connect(broken_receiver, sender=Tag, dispatch_uid="broken")
Tag.objects.create(name="炸一下")1
2create() 抛出:RuntimeError: 接收器炸了
但行数从 6 变成 7 —— post_save 是在 INSERT 之后发的,数据已经进去了 -
两个结论:
- 异常会原样冒出来,不会被吞(
send()的行为;send_robust()才会把异常收集进返回值)。 post_save时数据已经写库了,于是出现「接口 500,但数据其实存成功了」这种半完成状态。要避免就:- 把副作用放到
transaction.on_commit()里,保证提交成功才执行; - 或者把副作用交给 Celery,接收器只负责投任务(投任务本身很快)。
- 把副作用放到
- 异常会原样冒出来,不会被吞(
-
顺带一个反直觉的点:信号不感知事务。实测在
atomic()里保存:1
2
3
4
5
6order = []
with transaction.atomic():
Job.objects.create(job_name="事务里的岗位") # 这里 post_save 立刻进了 order
transaction.on_commit(lambda: order.append("on_commit"))
print(order) # ['post_save']
print(order) # ['post_save', 'on_commit']也就是说
post_save触发时事务还没提交,接收器里如果去查这条数据(另开连接)反而可能查不到。跨连接/跨进程的动作一律放on_commit。
请求、配置、登录这三类信号
-
用 Django 的测试客户端跑一次正常请求和一次 500,实测收到的顺序与参数:
1
2
3
4
5
6GET /jobs/ -> ['request_started', 'request_finished']
GET /jobs/boom/ -> ['request_started', 'got_request_exception', 'request_finished']
request_started sender=<class 'django.test.client.ClientHandler'> keys=['environ']
got_request_exception sender=None keys=['request']
request_finished sender=None keys=[]request_started的sender是处理器类(WSGI/ASGI),参数在 WSGI 下是environ,ASGI 下换成scope。request_finished什么额外参数都不给,sender是None。got_request_exception给的是request对象,异常本身不在参数里;要拿异常得从request上或日志里取。- 用测试客户端看 500 时记得
Client(raise_request_exception=False),否则异常会先抛回你的脚本。
-
setting_changed在override_settings进入和退出时各发一次,实测:1
2{'setting': 'TIME_ZONE', 'value': 'UTC', 'enter': True} # 进入:给新值
{'setting': 'TIME_ZONE', 'value': 'Asia/Shanghai', 'enter': False} # 退出:给旧值缓存了配置的组件(比如自己写的 settings 解析器)就靠它做失效。
-
登录相关三个:
1
2
3user_logged_in sender=User 类 keys=['request', 'user']
user_logged_out sender=User 类 keys=['request', 'user']
user_login_failed sender='django.contrib.auth' keys=['credentials', 'request']user_login_failed的sender是字符串'django.contrib.auth'(后端模块路径),不是类;credentials里的密码已经被替换成**,可以放心写日志。- 登录失败时
request可能是None(用authenticate()直接调用时就是None)。
实战:改状态时发通知到钉钉
-
这是旧笔记里那个例子的可跑版本。三个文件,全部实测过。
jobs/signal_processor.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36import json
import logging
from django.db.models.signals import post_delete, post_save
from django.forms.models import model_to_dict
from django.dispatch import receiver
from interview.dingtalk import send
from jobs.models import Job, Resume
logger = logging.getLogger(__name__)
def post_save_callback(sender, instance=None, created=False, **kwargs):
if isinstance(instance, Job):
message = "Job for %s has been saved" % instance.job_name
else:
message = "Resume for %s %s has been saved" % (
instance.username, instance.apply_position)
logger.info(message)
send(message)
def post_delete_callback(sender, instance=None, using=None, **kwargs):
obj = model_to_dict(instance, exclude=("picture", "attachment",
"created_date", "modified_date"))
message = "Instance of %s has been deleted: %s" % (
type(instance), json.dumps(obj, ensure_ascii=False))
logger.info(message)
send(message)
post_delete.connect(post_delete_callback, sender=Resume,
dispatch_uid="resume_post_delete")jobs/apps.py 1
2
3
4
5class JobConfig(AppConfig):
name = "jobs"
def ready(self):
import jobs.signal_processor # noqa: F401settings.py 1
2
3
4
5INSTALLED_APPS = [
"django.contrib.admin",
# ...
"jobs.apps.JobConfig", # 不要再单独写 "jobs"
] -
三个细节:
- 两个
@receiver叠在同一个函数上是可以的(实测两个 sender 各收到一次),但函数里必须用isinstance分流,否则instance.job_name会在Resume上炸。 - 旧笔记这里把
**kwarg写成了单数,接收器必须能接住**kwargs。 send()是同步阻塞的。生产上把这一行换成「投一个 Celery 任务」,钉钉的 200ms 就不会压在用户身上。
- 两个
自定义信号
-
三步:定义 → 注册接收器 → 触发。旧笔记这里有个拼写错误(
from self_signalimport my_signal,少空格),下面按能跑的版本写。1
2
3
4# jobs/signals.py 里定义(放在应用里,不要放项目根目录,免得 import 路径飘)
import django.dispatch
resume_deleted = django.dispatch.Signal()1
2
3
4
5
6
7
8
9# 注册
from jobs.signals import resume_deleted
def on_resume_deleted(sender, *, resume_id=None, **kwargs):
print("收到通知:sender=%s resume_id=%s" % (sender, resume_id))
resume_deleted.connect(on_resume_deleted, dispatch_uid="resume_deleted_notify")1
2
3
4# 触发:业务代码里
from jobs.signals import resume_deleted
resume_deleted.send(sender="Recruitment", resume_id=7)1
2收到通知:sender=Recruitment resume_id=7
send() 返回 [(接收器, 返回值)]:[('on_resume_deleted', None)] -
关于
Signal()的两个事实(实测):-
Signal(providing_args=[...])在 Django 4.0 就被删了,5.2 上直接报错:1
TypeError: Signal.__init__() got an unexpected keyword argument 'providing_args'
老教程里凡是带
providing_args的自定义信号,照抄必崩。参数约定改成写在文档/类型注解里。 -
Signal(use_caching=True)是构造参数:内置的模型信号都开了缓存,自定义信号默认没开。接收器很多时可以考虑开,但要注意它缓存的是弱引用版本。
-
-
send()与send_robust()的区别,实测:1
2send() 不吞异常:RuntimeError: 自定义信号的接收器炸了
send_robust() 把异常放进返回值:[('on_resume_deleted', None), ('bad', 'RuntimeError: 自定义信号的接收器炸了')]send()只要有一个接收器抛异常,后面的接收器不会执行,异常直接冒到调用方。send_robust()会把每个接收器的返回值(或异常)都收进列表,全部执行完再返回。业务通知类场景更合适。
-
触发信号时
sender是必填位置参数,其它都走关键字。接收器签名里sender一定要接住(def handler(sender, **kwargs))。
新手卡点速查
| 现象 | 原因 | 处理 |
|---|---|---|
| 接收器死活不执行 | 应用没注册 / 没在 ready() 里 import 接收器模块 |
INSTALLED_APPS 写 "jobs.apps.JobConfig",ready() 里 import jobs.signals |
| 接收器执行了两遍 | 应用被注册了两次,或接收器模块被重复 import | 检查 INSTALLED_APPS;给接收器加 dispatch_uid |
| 跑一段时间后才不执行 / 单元测试里不执行 | weak=True + 接收器是绑定方法或局部函数,对象被回收 |
用模块级函数,或 weak=False |
bulk_create 后没反应 |
它不发 save() 信号 |
改成循环 save(),或批量后手工补处理 |
| 接口 500 但数据已经存进去了 | post_save 在 INSERT 之后触发,接收器抛异常 |
接收器里别放会炸的逻辑;副作用挪到 on_commit 或 Celery |
| 接收器里查不到刚保存的数据 | 事务还没提交 | 用 transaction.on_commit() 包起来 |
TypeError: ... unexpected keyword argument 'providing_args' |
该参数在 Django 4.0 已移除 | 去掉 providing_args |
反向 add 报 AttributeError |
m2m_changed 的 instance 会随 reverse 换类型 |
先判 reverse / isinstance |
想在 pre_delete 存快照却读不到数据 |
用错信号,post_delete 时对象已不在库里 |
快照写进 pre_delete |
参考
- Signals — Django 5.2 documentation:
connect()参数、ready()注册、weak的说明。 - Built-in signals — Django 5.2:每个信号携带哪些关键字参数。
- django.dispatch — Django 5.2 API:
Signal/receiver的完整签名,以及providing_args的移除记录。 - bulk_create() 的注意事项:官方明确说明不会发送
save()信号。 - django/dispatch/dispatcher.py 源码:
lookup_key与use_caching的实现,解释了dispatch_uid为什么能去重。