ORM、@property 与 Celery 调试:
“为什么 .only() 里说不存在该字段?”
在设计安全性要求高的系统时,常会把 API Key 或 Secret 以加密形式存储在模型中,并通过 @property 在访问时只暴露解密后的值。
例如,结构大致如下:
_api_key→ 实际 DB 列(存储加密值)api_key→ 通过@property解密后返回api_key.setter→ 保存时自动加密
这种模式既保持了代码整洁,又符合安全要求。
但在与 ORM(以 Django ORM 为例)一起使用时,往往会不经意间踩到一个陷阱。
“我在模型里一直用
api_key,为什么 ORM 说字段不存在?”
本文记录了该陷阱的产生原因、调试难点以及如何避免。
1. 问题情境:
“我明明有值,ORM 说没有字段?”
在使用加密字段的模型时,某天 ORM 执行了以下代码:
Model.objects.only("api_key").first()
或:
Model.objects.values("api_key")
或:
Model.objects.filter(api_key__isnull=False)
结果总是相同:
FieldDoesNotExist: Model has no field named 'api_key'
开发者自然会想:
“我每天都用
obj.api_key,它是存在的。”
模型实际定义(简化示例)如下:
class Model(models.Model):
_api_key = models.CharField(db_column="api_key", max_length=255, null=True)
@property
def api_key(self):
if not self._api_key:
return None
return decrypt(self._api_key)
@api_key.setter
def api_key(self, value):
self._api_key = encrypt(value)
- 实例层:
obj.api_key正常工作 - 保存层:
obj.api_key = "plain-text"也正常
但 ORM 层却认为字段不存在。为什么?
2. 原因:ORM 识别的“字段名”不是属性名
核心很简单:
@property创建的api_key并不是真正的字段。
Django ORM 所说的“字段”,指的是继承自 models.Field 的子类。 也就是说,Model._meta.fields 中列出的都是实际字段。
在上述例子中,ORM 只知道:
_api_key = models.CharField(db_column="api_key", max_length=255, null=True)
这里:
- 模型字段名 →
_api_key - 数据库列名 →
"api_key" - @property api_key → ORM 完全不知道
总结表格:
我们日常使用的名字 (obj.api_key) |
ORM 认识的名字 (Model._meta) |
|---|---|
api_key (属性) |
_api_key (字段名) |
因此,ORM 里写:
Model.objects.only("api_key")
Model.objects.values("api_key")
Model.objects.filter(api_key__isnull=False)
会导致 ORM 认为“没有该字段”。
正确写法应使用字段名:
Model.objects.only("_api_key")
Model.objects.values("_api_key")
Model.objects.filter(_api_key__isnull=False)
3. Celery 内部为何更致命?
这类错误在 Django 视图里看起来只是“稍微麻烦”,但如果出现在 Celery 任务内部,后果更严重。
假设任务如下:
@shared_task(bind=True, max_retries=3)
def send_something(self, model_id):
try:
obj = Model.objects.only("api_key").get(id=model_id)
call_external_api(obj.api_key)
except SomeRecoverableError as exc:
raise self.retry(exc=exc)
真正的问题是:
only("api_key")抛出FieldDoesNotExist- 该异常被某个上层捕获,或被 Celery 误认为“可恢复错误”
- Celery 于是执行
self.retry(),导致任务不断重试
结果:
- Celery 任务一直在重试
- 外部 API 根本没有收到请求
- 日志里只看到“网络问题”或“外部服务器无响应”,很容易误判
这就是为什么调试难度会“爆炸性”提升的原因。
4. 解决方案:始终使用字段名
结论很简单:
属性只在实例层使用,ORM 查询时始终使用真正的字段名。
示例:
# 错误示例(会抛 FieldDoesNotExist)
obj = Model.objects.only("api_key").get(...)
# 正确示例
obj = Model.objects.only("_api_key").get(...)
real_key = obj.api_key # 这里再使用属性
过滤时同理:
# 错误
Model.objects.filter(api_key__isnull=False)
Model.objects.filter(api_key="plain-text")
# 正确
Model.objects.filter(_api_key__isnull=False)
Model.objects.filter(_api_key=encrypt("plain-text"))
如果想更安全,可以在团队层面制定规则:
- “以
_开头的字段仅在 ORM 中使用” - “对外暴露的属性名不用于 ORM 查询”
- 代码审查时检查
.values("...")、.only("...")、.filter(...)是否使用了属性名
5. 额外技巧:使用自定义字段隐藏加密逻辑
在大型项目中,可以考虑用自定义字段代替 @property。
示例:
class EncryptedCharField(models.CharField):
def get_prep_value(self, value):
if value is None:
return None
return encrypt(value)
def from_db_value(self, value, expression, connection):
if value is None:
return None
return decrypt(value)
class Model(models.Model):
api_key = EncryptedCharField(max_length=255, db_column="api_key", null=True)
这样:
- ORM 认为
api_key是真正字段 - 代码层仍可直接
obj.api_key使用 .only("api_key")、.values("api_key")、.filter(api_key__isnull=False)都能正常工作
缺点:
- 需要实现并测试自定义字段
- 迁移和兼容性需注意
根据团队规模和需求,选择“简单的 @property + 规则”或“自定义字段”即可。
6. 经验总结:细节决定成败
这次经历的教训很直白:
@property并不属于 ORM 字段- 在 Celery 等异步环境中,错误可能被隐藏在重试机制里
- 代码审查时要特别留意 ORM 查询是否使用了属性名
记住以下四点即可大幅减少类似问题:
- ORM 只识别模型字段名
@property不能用于 ORM 查询- 加密/解密模式下,字段名与属性名易混淆
- Celery 内部的错误可能被误认为网络问题
7. 归纳
1) ORM 只理解“模型字段名”
Model._meta.fields为唯一来源@property不在其中
2) @property 不能用于 ORM 查询
.only("api_key")、.filter(api_key=...)均错误- 必须使用
_api_key
3) 加密/解密模式需更谨慎
- 字段名与属性名相似易混
- 代码审查时要检查
4) Celery 内部更危险
- 异常被重试机制吞掉
- 看似网络问题,实则字段错误
通过合理的命名约定和代码审查,可避免类似问题耗费大量时间。

目前没有评论。