Django连续请求导致Session属性被覆盖的问题排查与解决方案咨询
订单导出功能的Session冲突问题分析与解决
问题根源:并行请求导致的Session写入竞态
从你的日志和代码来看,问题出在浏览器同时打开两个导出窗口时的Session并行写入冲突:
当订单已支付时,前端JS会同时调用window.open()发起发票和收据的导出请求,这两个请求几乎同时到达服务器:
- 第一个请求(比如发票)读取当前Session的初始状态(还没有任何
_*_token字段),把签名token存入_invoice_token,然后重定向 - 第二个请求(收据)也读取到完全一样的初始Session状态,把同一个token存入
_receipt_token,然后重定向 - 因为Django的Session默认是覆盖式写入的,第二个请求的Session修改会覆盖第一个请求刚写入的
_invoice_token字段 - 当处理发票的重定向请求时,去Session里找
_invoice_token,发现它已经被覆盖没了,自然就抛出"invalid signature"错误
简单说就是两个请求抢着写Session,最后只有后写的那个字段存活,先写的被覆盖了。
不暴露token的解决方案
下面几个方案都能解决问题,而且不需要把签名token直接暴露在最终的URL里:
方案1:给请求加个延迟,让它们串行执行
这是最简单的临时修复,修改模板里的JS,让收据的请求晚一点发起,避免两个请求同时打给服务器:
<script> ... {% if messages %} {% for msg in messages %} {% if msg.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} // 先打开发票窗口 window.open("{% url 'sale:export' 'invoice' msg %}", "_blank"); {% if msg.level == DEFAULT_MESSAGE_LEVELS.INFO %} // 延迟500ms再打开收据,错开请求时间 setTimeout(() => { window.open("{% url 'sale:export' 'receipt' msg %}", "_blank"); }, 500); {% endif %} {% elif msg.level == DEFAULT_MESSAGE_LEVELS.INFO %} window.open("{% url 'sale:export' 'receipt' msg %}", "_blank"); {% endif %} {% endfor %} {% endif %} </script>
这个方法成本最低,不需要改后端代码,适合快速解决问题。
方案2:用原子化的Session更新避免覆盖
Django默认的Session写入是读取快照-修改-覆盖保存,并行请求会冲突。我们可以直接操作SessionStore,确保每次写入都是基于最新的Session状态:
# 在ExportView的get方法中,替换原来的request.session[action['session_prop']] = kwargs['token'] from django.contrib.sessions.backends.db import SessionStore def get(self, request, *args, **kwargs): # ... 其他原有逻辑 ... else: # 显式加载最新的Session数据,再写入字段,避免覆盖 session_store = SessionStore(session_key=request.session.session_key) session_store.load() session_store[action['session_prop']] = kwargs['token'] session_store.save(must_create=False) # ... 原有重定向逻辑 ...
这个方法能从根源上解决Session的竞态问题,适合需要保持原有重定向逻辑的场景。
方案3:用缓存存临时映射,彻底脱离Session
我们可以生成一个唯一的临时token,把它和原始签名token、操作类型绑定存在缓存里,这样完全不用依赖Session,也就不会有冲突:
- 修改ExportView的重定向逻辑:
# 在ExportView的get方法中 from django.core.cache import cache import uuid def get(self, request, *args, **kwargs): # ... 原有检查action的逻辑 ... action = self.actions[kwargs['action']] if kwargs['token'] == action['url_token']: # 从缓存取原始token temp_token = kwargs['token'] data = cache.get(f"export_{temp_token}") if not data or data[1] != kwargs['action']: return render(request, '400.html', {'msg': 'invalid signature'}, status=400) sign = signing.load(data[0]) cache.delete(f"export_{temp_token}") # 用完就删,避免重复使用 return getattr(self, kwargs['action'])(sign) else: # 生成唯一临时token temp_token = str(uuid.uuid4()) # 把原始token和操作类型存入缓存,设置5分钟过期 cache.set(f"export_{temp_token}", (kwargs['token'], kwargs['action']), timeout=300) # 重定向到带临时token的URL redirect_url = request.path.replace(kwargs['token'], temp_token) return redirect(redirect_url)
这个方法安全性更高,临时token过期自动失效,而且完全不会有Session冲突的问题。
方案4:改用POST请求,彻底跳过Session和重定向
把GET请求改成POST请求,把签名token放在表单的隐藏字段里,这样URL里完全不会出现token,同时也避免了并行请求的Session问题:
- 修改模板,用隐藏表单代替window.open:
{% if messages %} {% for msg in messages %} {% if msg.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} <form method="POST" action="{% url 'sale:export' 'invoice' %}" target="_blank"> {% csrf_token %} <input type="hidden" name="token" value="{{msg}}"> <button type="submit" style="display:none;" id="submit-invoice-{{msg}}"></button> </form> {% endif %} {% if msg.level == DEFAULT_MESSAGE_LEVELS.INFO %} <form method="POST" action="{% url 'sale:export' 'receipt' %}" target="_blank"> {% csrf_token %} <input type="hidden" name="token" value="{{msg}}"> <button type="submit" style="display:none;" id="submit-receipt-{{msg}}"></button> </form> {% endif %} {% endfor %} {% endif %} <script> ... {% if messages %} {% for msg in messages %} {% if msg.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} document.getElementById('submit-invoice-{{msg}}').click(); {% if msg.level == DEFAULT_MESSAGE_LEVELS.INFO %} setTimeout(() => { document.getElementById('submit-receipt-{{msg}}').click(); }, 500); {% endif %} {% elif msg.level == DEFAULT_MESSAGE_LEVELS.INFO %} document.getElementById('submit-receipt-{{msg}}').click(); {% endif %} {% endfor %} {% endif %} </script>
- 修改urls.py,去掉URL里的token参数:
path('orders/export/<action>/', views.ExportView.as_view(), name='export'),
- 修改ExportView支持POST请求:
class ExportView(View): http_method_names = ['get', 'post'] # ... 原有actions定义 ... def post(self, request, *args, **kwargs): if kwargs['action'] not in self.actions: return render(request, '400.html', {'msg': 'undefined action'}, status=400) token = request.POST.get('token') if not token: return render(request, '400.html', {'msg': 'missing token'}, status=400) try: sign = signing.load(token) except: return render(request, '400.html', {'msg': 'invalid signature'}, status=400) return getattr(self, kwargs['action'])(sign) # ... 原有get方法可以保留,也可以删掉,看需求 ...
这个方法是最安全的,token完全不会出现在URL里,也彻底解决了Session的竞态问题。
内容的提问来源于stack exchange,提问作者Ezon Zhao
相关产品推荐
相关产品推荐

