并发创建与检索操作下Django MPTTModel树形结构在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

