Django 模板缓存设计

问题

项目中的模板渲染速度特别慢,模板又特别多,逐个修改模板不太现实,只能通过有限制的批量替换以及对 context 做一些处理实现速度优化。

QuerySet 如何缓存

Django 的 QuerySet 是 lazy 的,只在一些特定情形下执行,细节可以参考 doc , 这样就带来了一个问题,比如以下很常见的代码,其中的 object_list 是一个 QuerySet

{% for obj in object_list|slice:":10" %}
{{ obj.name }}
{% endfor %}

由于 slice 操作是在模板中的,如何在不更改模板的前提下缓存 QuerySet ?

很容易想到重写 slice,slice 虽然是一个内置的 template tag ,但是也是通过 @register.filter() 注册的,非常方便覆盖。

@register.filter("slice", is_safe=True)
def slice_filter(value, arg):
    try:
        bits = []
        for x in arg.split(':'):
            if len(x) == 0:
                bits.append(None)
            else:
                bits.append(int(x))
        slice_object = slice(*bits)
        if slice_object.start or slice_object.stop:
            if isinstance(value, (models.QuerySet)):
                if value.model is User:
                    # Do something you want...

        return value[slice_object]

    except (ValueError, TypeError):
        return value

上述代码展示了重写 slice ,可以通过 isinstance(value, (models.QuerySet)) 判断前置类型,还可以通过 value.model 判断 对应的 Model,这样似乎可以实现缓存逻辑。

但是,很多时候缓存要求非常精细,比如,需要区分不同 User 做不同的缓存,在不执行 QuerySet 的前提下无法读出数据,也就无法判断是哪个 User 的数据。当然也不是没有办法,比如如果 QuerySet 是通过 QuerySet.filter(user_id=1) 类似的语句生成的,可以访问 QuerySet 的属性 拿到具体的 WHERE 查询对象,通过 WHERE 条件判断 QuerySet 的意图。但是这样做比较复杂而且丧失了通用性。

还有一种方法是给 QuerySet 对象加上一层代理,根据 Python magic method 的规则,for 循环会首先调用 __iter__ 方法,而 slice 会调用 __getitem__ 方法,参考如下。

class QuerySetProxy(object):

    def __init__(self, queryset):
        self._queryset = queryset

    def __iter__(self):
        cache_value = get_cache()
        if cache_value is hit:
            return cache_value
        cache_value = list(self._queryset.__iter__())
        set_cache(cache_value)
        return cache_value

    def __getitem__(self, k):
        if isinstance(k, slice):
            cache_value = get_cache()
            if cache_value is hit:
                return cache_value
            cache_value = list(self._queryset.__getitem__(k))
            set_cache(cache_value)
                return cache_value
        return self._queryset.__getitem__(k)

然后,上述代码中的 set_cache 是如何区分不同 QuerySet 的呢?有个方法是计算 SQL 的 hash 值判断唯一性。通过 str(QuerySet.query) 可以拿到原始的 SQL 查询语句。

缓存的清除问题

缓存的清除是一个很头疼的问题,简单的想到两个自动化的方法,一个是通过 post_save 之类的信号;另一个是在 API 入口处统一处理,判断 REST API 的 method 为 POST, PUT, DELETE, PATCH 时可以进行对应的缓存操作。

模板片段的缓存

上述缓存 QuerySet 之后,对于模板变量特别多的片段,效果并不是特别好,对于这种模板直接缓存渲染之后的片段效果会更好。

Django 自带了一个 cache tag github ,支持设置时间和 key,但是不够智能,因为需要手动设置 key 并不灵活。或许我们可以通过获取模板文件路径和行号作为缓存 key ?思路是对的,但是 Django 只有在开启 template_debug 的时候才能在 tag 的作用域内访问到模板文件元信息。

好在每个 Node 对象都可以获得 child_nodelists ,然后递归寻找子 Node,而且对于 TextNode 可以访问到 text 属性!递归一遍可以计算出模板片段的 Hash。

class CacheNode(Node):

    def get_node_hash(self, context):
        for attr in self.child_nodelists:
            if hasattr(self, attr):
                print getattr(self, attr)