You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

并发创建与检索操作下Django MPTTModel树形结构在Redis缓存中损坏的问题咨询

解决django-mptt + django-cacheops并发读写导致Redis缓存损坏的问题

我之前在维护一个层级数据系统时也碰到过几乎一模一样的问题——用django-mptt管理树形结构,配合django-cacheops做Redis缓存,高并发下创建节点时总会出现缓存数据错乱的情况,折腾了好一阵才找到几个可行的方案,分享给你:

问题核心再梳理

本质原因是django-mptt的节点创建流程分两步:先写入基础节点数据到数据库(此时lft/rght等mptt核心字段还未完成计算),再执行树形结构的调整逻辑来修正这些字段。如果在这两步之间有读请求进来,django-cacheops就会把不完整的节点数据缓存到Redis,后续请求再读就拿到了损坏的层级信息。

可行解决方案

1. 调整缓存触发时机,延迟到mptt结构更新完成后

django-cacheops默认在模型post_save信号触发时缓存数据,但mptt的结构更新是在post_save之后执行的(或同一事务内的后续步骤)。我们可以自定义缓存触发逻辑,让缓存只在树形结构完全调整好后才生效:

  • 监听django-mptt的moved信号(该信号会在节点树形位置完全更新后触发),在信号回调里手动触发缓存写入;
  • 同时禁用post_save时的默认缓存逻辑,避免提前缓存不完整数据。

示例代码:

from django.db.models.signals import post_save
from mptt.signals import moved
from cacheops import invalidate_obj
from .models import Content

# 禁用post_save时的自动缓存,提前清除可能生成的无效缓存
@receiver(post_save, sender=Content)
def skip_default_cache_on_save(sender, instance, **kwargs):
    invalidate_obj(instance)

# 等mptt结构调整完成后,再缓存完整的节点数据
@receiver(moved, sender=Content)
def cache_after_mptt_sync(sender, instance, **kwargs):
    instance.refresh_from_db()  # 确保拿到最新的lft/rght字段值
    # 访问实例字段触发cacheops的缓存逻辑
    _ = instance.lft

2. 用事务+缓存锁控制并发读写

由于是微服务架构,多服务并发请求,我们可以把节点创建的全流程(包括mptt结构更新)包裹在数据库事务内,同时给目标节点加临时缓存锁,让读请求在事务提交前直接绕过缓存读数据库:

from django.db import transaction
from cacheops import cache, invalidate_obj
from .models import Content

def create_content_node(parent, **data):
    lock_key = f"content_tree_lock_{parent.id}"
    with transaction.atomic():
        try:
            # 设置10秒超时的缓存锁,防止异常导致锁一直存在
            cache.set(lock_key, "locked", timeout=10)
            # 创建节点,mptt会自动处理树形结构更新
            node = Content.objects.create(parent=parent, **data)
            # 事务提交后,更新缓存
            invalidate_obj(node)
            invalidate_obj(parent)  # 父节点的子树结构变化,同步失效缓存
        finally:
            # 无论是否成功,都释放锁
            cache.delete(lock_key)

# 读取时判断锁状态,有锁则直接读库
def get_content_node(node_id):
    node = Content.objects.get(pk=node_id)
    lock_key = f"content_tree_lock_{node.parent.id if node.parent else 'root'}"
    if cache.get(lock_key):
        # 存在锁,直接返回数据库最新数据,不读缓存
        node.refresh_from_db()
        return node
    # 正常走缓存逻辑
    return node

3. 优化缓存粒度,缓存整个子树而非单个节点

单个节点的缓存容易因为局部数据不完整导致损坏,改成缓存整个父节点下的子树,创建节点时直接失效对应子树的缓存,后续读请求会重新从数据库拉取完整的子树数据并缓存:

from cacheops import cached_as, invalidate_obj
from .models import Content

# 缓存指定父节点下的完整子树
@cached_as(Content, key=lambda parent: f"content_subtree_{parent.id}")
def get_full_subtree(parent):
    return list(parent.get_descendants(include_self=True))

# 创建节点后,直接失效父节点的子树缓存
def create_content_node(parent, **data):
    node = Content.objects.create(parent=parent, **data)
    invalidate_obj(parent)  # 触发子树缓存重新生成

4. 切换为"写失效"缓存策略

django-cacheops默认是"写更新"(保存后直接更新缓存),改成"写失效"的话,创建或更新节点时直接删除对应缓存,让读请求在缓存缺失时去数据库拉取最新的完整数据,再重新缓存:

在settings.py中调整cacheops配置:

CACHEOPS = {
    'myapp.Content': {
        'ops': 'get',
        'cache_on_save': False,  # 禁用保存时自动更新缓存
        'timeout': 900  # 15分钟缓存有效期
    },
}

然后在mptt结构更新完成后手动失效缓存:

from mptt.signals import moved
from cacheops import invalidate_obj
from .models import Content

@receiver(moved, sender=Content)
def invalidate_content_cache(sender, instance, **kwargs):
    invalidate_obj(instance)
    # 父节点的子树结构变化,同步失效父节点缓存
    if instance.parent:
        invalidate_obj(instance.parent)

内容的提问来源于stack exchange,提问作者nick

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.01 03:12:30