美文网首页
一个由Django的save方法引发的bug

一个由Django的save方法引发的bug

作者: 不_一 | 来源:发表于2018-03-18 16:05 被阅读0次

    引自
    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

    有兴趣的同学可自行阅读。

    相关文章

      网友评论

          本文标题:一个由Django的save方法引发的bug

          本文链接:https://www.haomeiwen.com/subject/tmweqftx.html