引自
bug的产生
我已经很久没碰到让人比较头疼的bug了,前两天的时候碰到了一个。写个文章记录下来。 希望看过文章的同学下次碰到类似bug就不用被烦恼到。
出现问题的这段代码简化过的逻辑大概是这个样子的:
from app.models import User
from celery.task import task
... ...
# 假设我们取到的user的age和name都是None
user = User.objects.get(pk=1)
# 开启两个task来执行任务
update_user_age.delay(user)
update_user_name.delay(user)
# Celery tasks
@task
def update_user_age(user):
"""
接受一个User实例,然后修改它的年龄
"""
user.age = 38
user.save()
@task
def update_user_name(user):
"""
接受一个User实例,然后修改它的名字
"""
user.name = 'piglei'
user.save()
这段代码的执行结果是,user的age和name字段永远 不会都被赋上值 ,要么是有name没有age, 要么有age没有name。
怎么样?能一眼能看出来问题在什么地方吗?如果能看出来的话,可以直接跳到最后一段啦。 不过当时笔者碰到的代码比这个要复杂不少,有一些task是通过django的signals触发 调用的,所以给排查产生了很大的麻烦,花了一些功夫才找到问题。
对于那些不能看出来问题的同学,且往下看。
问题是如何产生的?
所有学过django的人都知道,在django中修改单个Model对象,一般是这样做的:
from app.models import User
user = User.objects.get(pk=1)
user.age = 38
user.save()
我们先获取一个实例,然后修改这个实例的某个属性,最后调用save方法去操作数据库,大功告成。 那我们的改动是怎么被回写到数据库的呢?是在最后调用save方法的时候,django最后会生成并执行一条这样的UPDATE语句:
UPDATE "user_table_name" set age = 38, name = 'name_value' ... ...
WHERE "user_table_name".id = USER_PK;
我们可以注意到, 不光是你修改过的age字段被提交到了数据库,其他你并没有修改过的字段,也被放在update的参数里面一并被提交过去了。 当你的所有操作都是串行,没有什么并发同时操作同一个model的时候,这样的处理方式,一般不会给你带来任何麻烦。
但是像我们前面所描述的使用环境,就很可能会产生问题。当我们往celery队列里面丢一个task过去的时候,所有的参数会先经过序列化,然后丢进队列里,这样当worker拿到参数开始执行的时候,传过来的这个Model实例里面很有可能会有某些字段是 “已经过时“ 的。
比如上面例子中的 user 对象,在作为参数传到 update_user_age 和 update_user_name 任务的同时。它的name和age参数都是None,所以在这两个task里面,修改了 user 的某一个字段后,调用save方法保存时。另外一个没被修改的字段也被当做None放在了UPDATE语句里回写到了数据库。
这样便导致了问题。
解决办法
知道了这个问题的原因之后,要解决起来其实也是比较简单的。最简单的解决办法只要让celery处理的时候不要调用save方法即可。
我使用了一个django snippet:
def update(instance, **kwargs):
"Atomically update instance, setting field/value pairs from kwargs"
# fields that use auto_now=True should be updated corrected, too!
for field in instance._meta.fields:
if hasattr(field, 'auto_now') and field.auto_now and field.name not in kwargs:
kwargs[field.name] = field.pre_save(instance, False)
rows_affected = instance.__class__._default_manager.filter(pk=instance.pk).
update(**kwargs)
# apply the updated args to the instance to mimic the change
# note that these might slightly differ from the true database values
# as the DB could have been updated by another thread. callers should
# retrieve a new copy of the object if up-to-date values are required
for k,v in kwargs.iteritems():
if isinstance(v, ExpressionNode):
v = resolve_expression_node(instance, v)
setattr(instance, k, v)
# If you use an ORM cache, make sure to invalidate the instance!
#cache.set(djangocache.get_cache_key(instance=instance), None, 5)
return rows_affected
这个update方法主要是实现了原子级别的update。用法很简单,把你需要修改的字段和内容作为**kwargs传进去调用即可。这样就避免了使用save时产生的一些不必要的修改。
把前面例子中的task里面的save方法改写成update,问题就能得到解决了。
update(user, age=age)
update(user, name=name)
Django新版本对这样的情景提供的支持
其实在Django 1.5以后的版本,已经对这样的情景提供了支持:
https://docs.djangoproject.com/en/1.6/ref/models/instances/#specifying-which-fields-to-save
在save方法里面新增加了一个update_fields参数。这样就可以只修改特定字段了:
user.name = name
user.save(update_fields=['name'])
这样便有效避免了并行save产生的数据冲突。不过笔者目前的生产环境还是1.3,所以还是暂时用之前的update方法顶着了。
思考:为什么Django不自动追踪哪些字段被修改过?
看过以上的这些内容,可能有很多人和我一样有一个疑问,为什么Django不自动追踪每一个instance被修改过的字段,然后在调用save方法的时候,只把这些被修改过的字段组合到UPDATE语句里去呢?这样就解决了并发修改数据冲突的问题。
通过一些搜索,我在django的ticket历史里面找到了一些讨论:
https://code.djangoproject.com/ticket/4102
这个ticket从最早的patch讨论到现在已经七年了!!!!主要是关于为save方法提供自定义的update_fields字段的。在最后的两个comment有提到另外一个项目 django-save-the-change ,这个项目实现了我们想要的自动只save修改过的字段过的功能。
下面这个ticket里面,这个项目作者对于为什么这个项目没有被加入django作为默认的feature发表了一些看法。
https://github.com/karanlyons/django-save-the-change/issues/1
有兴趣的同学可自行阅读。
网友评论