Django实时日志系统:用AJAX轮询与subprocess实现Python任务输出实时展示
1. 项目概述
先说清楚这个项目到底在解决什么问题。标题里的核心词拆开看就是:Django 后端跑一个 Python 任务,这个任务的 print 输出本来只出现在服务器终端里,现在我想让浏览器页面实时看到这些日志,而且要像终端滚动一样一行一行蹦出来。这个场景我猜很多人在实际工作里都遇到过,比如后台跑模型推理、批量数据处理、爬虫任务,或者部署脚本——页面点一个按钮,任务开始执行,前端一直白屏,用户就会反复问“到底跑没跑?跑到哪一步了?”。传统做法是让他登录服务器看日志文件,但对非技术人员来说这根本不现实。
我做的这套方案没有引入 Celery,没有上 WebSocket,也没有装任何消息队列,就用 Django 自带的能力加上原生的 AJAX 轮询,用最少的依赖把这个需求解决了。整个系统就三块:后端用subprocess起子进程去跑 Python 脚本,同时把 stdout 重定向到一个管道里,用读线程不断把输出塞进内存队列;然后暴露一个带offset的增量日志接口;前端用setInterval定时fetch这个接口,把新到的日志追加到页面上,任务结束就自动停轮询。整个实现大概两三百行代码,全部代码可以贴在一篇博文里讲清楚。
这个方案适合谁看?我觉得做 Web 开发、写自动化平台、搞内部工具的人都能从中拿到思路。如果你正想给后端的脚本任务做一个“在线终端”效果,又不想为了一个日志展示功能把项目升级成 ASGI 异步架构,那这套 Ajax 轮询方案就是你最合适的切入点。
1.1 这个需求是怎么冒出来的
有一次我接手了一个内部的数据处理平台,其中一个页面要触发一个需要跑十几分钟的 Python 脚本。最初版本的设计是点击按钮后直接弹出“任务已提交”,用户只能在任务列表页面看到“运行中”或者“已完成”。问题很快就来了,脚本跑到一半如果卡住了,用户不知道是还在正常处理还是已经死掉了;跑完了是成功还是失败,失败原因是什么,也得等状态刷新,完全靠猜。用户提需求说得很直接:“你能不能像看直播一样让我看着它跑?”
这就是需求的原点。最简单的实现办法,是把脚本的标准输出直接接到响应流里,用 StreamingHttpResponse 让用户在请求期间实时看到内容。但这样做有几个毛病:第一个是请求和任务生命周期强绑定,用户浏览器一刷新,任务输出连接就断了,但任务本身还在跑,重新连接也不可能从头看起;第二个是请求一直挂在那里,代理服务器和浏览器都有超时风险,页面一卡就像崩了。所以最终选择了“任务在后台跑 + 前端轮询拉取”这种解耦方案。任务和页面不再互相依赖,刷新页面还能接上之前的日志继续看。
1.2 为什么不用现成的日志系统
如果有条件,确实可以直接上成熟的日志收集系统,比如 ELK 或者 Loki 之类,让任务脚本直接把日志打到日志服务,前端再从日志服务查询。但这套架构对内部小工具来说太重了,你得额外部署组件,配置采集端,处理索引和权限,运维成本一下就上去了。而且任务级别的实时流式输出,在通用日志系统里的展示体验也未必能满足需求——你更想要的其实是一个“终端窗口”的感觉,而不是日志查询页面。
另外我也考虑过用 Django Channels 上 WebSocket,做真正的服务端主动推送,体验确实更好,但代价是要把项目从 WSGI 改成 ASGI,引入通道层和 Redis 作为消息后端。为了一个日志展示功能破坏现有项目的部署方式,我当时觉得不值得。Ajax 轮询虽然看起来土,但胜在简单、可控、稳定,而且对这个场景来说延迟完全够用。任务日志又不是打游戏,几百毫秒的延迟用户感知不到任何差别。
2. 方案选型思考:Ajax 轮询为什么够用
写技术方案最怕的不是功能实现不了,而是为了“先进”而引入一堆自己根本驾驭不住的东西。我当时把可能的技术路线都摆在一起对比过,这里把结论分享出来,大家以后遇到类似场景可以直接照着判断。
2.1 三种实时方案的对比
我重点对比了三类方案:纯 Ajax 轮询、SSE(Server-Sent Events)和 WebSocket。各有利弊,但适用场景完全不同。
| 特性 | Ajax 轮询 | SSE | WebSocket |
|---|---|---|---|
| 实现复杂度 | 低,无需额外依赖 | 中,需要 StreamResponse 或第三方包 | 高,需要 ASGI + Channels |
| 数据流向 | 客户端主动拉取 | 服务端单向推送 | 双向推送 |
| 断线重连机制 | 需要自己写 | 原生支持自动重连 | 需要自己处理 |
| 浏览器兼容性 | 全兼容 | 除老 IE 基本兼容 | 全兼容 |
| 实时性 | 取决于轮询间隔 | 毫秒级 | 毫秒级 |
| 服务器资源占用 | 每个请求一次握手 | 长连接占用一个连接 | 长连接占用一个连接 |
WebSocket 体验最好,这是毋庸置疑的,但它的成本和收益在当前这个场景下不成正比。项目里跑的任务多,日志也只是单方向从服务器到浏览器,根本不需要客户端往服务端实时发数据,用双向全双工协议有点杀鸡用牛刀。SSE 其实非常契合这种单向日志流,它比 WebSocket 轻量,原生支持断线自动重连,我后来在其它项目里也确实用过。但当时考虑到项目主体的 Django 版本还跑在 WSGI 模式下,引入 SSE 还得处理代理缓冲问题(Nginx 默认会缓冲响应,不关掉的话 SSE 消息会攒在一起发出去),而这些坑对 Ajax 轮询来说根本不存在。
2.2 轮询方案的设计要点
确定了轮询方向之后,有几个设计细节直接决定方案好不好用。
第一个是“增量”不是“全量”。最愚蠢的轮询接口写法是每次都把当前所有日志全量返回,前端拿到之后整个清空重绘。日志少的时候看不出问题,日志一大,页面就会疯狂闪烁、滚动条被反复重置,而且传输的数据量随任务时长线性膨胀。正确做法是后端给每一行日志分配一个自增序号,前端每次请求带上自己已经消费到的序号,后端只返回从这个序号之后的新数据。这样无论任务跑多久,每次请求的响应体都只包含几十行新增内容,页面流畅度和服务端压力都完全可控。
第二个是“轮询必须能自动停止”。任务结束后前端要继续拉取最后一次结果,拿到退出码并把按钮恢复可用状态,然后立刻清掉定时器,不能让它永远空转下去。我曾经见过同事写的轮询代码,任务都删了,请求还在每隔两秒发一次,变成无效请求刷服务器日志。这种问题一旦上线,排查起来非常隐蔽,因为页面看起来正常,只是服务器日志多了很多 404。
第三个是“前端要处理好瞬时抖动”。轮询请求偶尔失败是常态,比如浏览器缓存策略异常、网络抖动、服务端平滑重启,这时候不能直接把整个轮询停掉,应该跳过本次循环等下一个周期再试。但如果连续失败次数超过阈值,就得提示用户页面可能断开了,而不是静默失败。
3. 后端实现:subprocess 捕获与内存队列
现在进入最核心的部分:Django 后端怎么在内存里维护一个任务的实时日志流。这里的关键点在于,你不仅仅需要启动一个 Python 子进程,还要让它的所有输出实时可见、可查询、可追溯。
3.1 核心思路:子进程加管道加队列
我先解释一下数据流:当浏览器触发一个任务时,Django 视图函数用subprocess.Popen启动一个新的 Python 解释器进程去跑目标脚本,同时把子进程的stdout和stderr都重定向到管道。然后后端启动一个守护线程,不停从管道里按行读取文本,每读到一行就往一个全局字典里的deque队列尾部塞。前端每次轮询接口时,后端从队列里按 offset 切片返回新增行,同时检查子进程的poll()返回值来判断任务是否已经结束。
浏览器 <---AJAX---- Django 视图 <---全局队列--- 读线程 <---管道--- 子进程脚本这个架构里,队列的核心价值是解耦了“产生日志的线程”和“消费日志的 Http 请求”。子进程的输出速率是不均匀的,可能一瞬间刷几百行,也可能几分钟没输出;而前端的请求是固定间隔的。如果不用队列中间缓存,Log 输出的写入和读取就会互相阻塞,两边都别扭。deque本身就是线程安全的,append 和切片在 CPython 里由于 GIL 的存在不会出现明显的数据竞争问题,小额并发场景足够可靠。我还给它设置了maxlen=2000,超过 2000 行自动丢弃最早的行,防止长时间运行的任务把服务器内存吃爆。
以下是任务启动的完整代码,我用的是 Django 3.2 左右的版本,核心逻辑在 2.x 和 4.x 也完全通用:
import os import sys import time import uuid import threading import subprocess from collections import deque from django.http import JsonResponse from django.views.decorators.http import require_http_methods # 全局任务注册表:task_id -> task_dict TASKS = {} TASKS_LOCK = threading.Lock() @require_http_methods(["POST"]) def start_task(request): # 实际项目中建议用 JSON body,这里为了方便演示用 form 字段 script_path = request.POST.get("script", "").strip() if not script_path: return JsonResponse({"error": "缺少 script 字段"}, status=400) if not os.path.isfile(script_path): return JsonResponse({"error": "脚本文件不存在"}, status=400) task_id = uuid.uuid4().hex[:8] buffer = deque(maxlen=2000) # 关键1:PYTHONUNBUFFERED 强制子进程不缓冲输出 env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" # 关键2:stdout 和 stderr 合并到同一个管道 # 关键3:text=True 让管道以文本模式读取,而不是字节 # 关键4:bufsize=1 表示行缓冲 proc = subprocess.Popen( [sys.executable, "-u", script_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=env, bufsize=1, text=True, encoding="utf-8", errors="replace", cwd=os.path.dirname(os.path.abspath(script_path)) or None ) # 守护线程:不断读取子进程输出,塞进队列 def reader(proc, buffer): try: for line in proc.stdout: buffer.append((time.time(), line.rstrip("\n"))) except Exception: # 读取过程中任何异常都不能影响主流程 pass finally: # 确保子进程被回收 if proc.poll() is None: proc.wait() thread = threading.Thread(target=reader, args=(proc, buffer), daemon=True) thread.start() task_info = { "proc": proc, "buffer": buffer, "thread": thread, "started_at": time.time(), "status": "running", } with TASKS_LOCK: TASKS[task_id] = task_info return JsonResponse({ "task_id": task_id, "message": "任务已启动", })这里有两个细节值得展开。第一个是PYTHONUNBUFFERED环境变量和-u参数本质上是一回事,我两个都写上是为了保险——环境变量覆盖到子进程内部使用的所有 Python 入口,-u参数确保即使脚本内部用subprocess再拉起孙进程也会继承这个不缓冲特性。第二个细节是cwd,如果不把工作目录切到脚本所在目录,脚本内部如果存在相对路径的读写,很可能会以 Django 进程的当前目录为基准去解析,导致“文件找不到”之类的玄学错误。这两个坑我都踩过,加上上面的写法之后基本没再出过问题。
3.2 增量日志读取接口
任务启动后,前端需要一个接口来持续拉取日志。这个接口的核心设计就是 offset 增量。
from django.http import JsonResponse from django.views.decorators.http import require_http_methods @require_http_methods(["GET"]) def get_logs(request, task_id): with TASKS_LOCK: task = TASKS.get(task_id) if task is None: return JsonResponse({"error": "task not found"}, status=404) buffer = task["buffer"] proc = task["proc"] # offset 是前端已经消费过的行数 try: offset = int(request.GET.get("offset", 0)) except (TypeError, ValueError): offset = 0 if offset < 0: offset = 0 lines = list(buffer)[offset:] offset_new = offset + len(lines) # 子进程是否结束 finished = proc.poll() is not None return JsonResponse({ "lines": lines, "offset": offset_new, "finished": finished, "exit_code": proc.returncode if finished else None, })为什么用行列偏移而不是时间戳?因为deque在内存中就是顺序结构,用下标切片天然简单。时间戳做偏移看着更“高级”,但同一秒内可能出现多行日志,时间戳粒度不够会导致重复推送或漏消息。当然这里有个前提:读线程写入队列是唯一的,所以list(buffer)[offset:]的结果按写入顺序排列是稳定的。我这个方案不考虑多个写入线程同时往同一个任务写日志的情况,如果有那个需求,就得改成每个线程一个队列再加合并逻辑。
offset 还有一个优点是可重放。用户刷新页面之后,前端从服务器拿一次任务元信息,把 offset 归零,就能从第一行日志重新拉到当前最新位置,历史记录完整展示。这在任务执行到一半、用户误触刷新的场景下体验特别重要。
3.3 任务与资源管理
跑完的任务如果不清理,子进程对象和队列会一直躺在全局字典里,变成一次内存泄漏。所以还要额外写一个兜底的清理逻辑。
import time def reap_finished_tasks(max_age=7200): """清理已经结束且超过保留时限的任务,避免内存无限增长""" current_time = time.time() with TASKS_LOCK: finished_ids = [] for task_id, task in TASKS.items(): if task["proc"].poll() is not None: if current_time - task["started_at"] > max_age: finished_ids.append(task_id) for task_id in finished_ids: del TASKS[task_id]这个清理函数不需要用户主动调用,你可以放在 Django 的定时任务里跑,也可以简单地写在每次start_task之前调用一次。我自己的项目里是加了一个装饰器,在每个日志请求进来时顺带扫一遍,逻辑很简单但能把内存控制住。还有一个容易被忽略的资源:读线程。读线程是 daemon 线程,子进程跑完后for line in proc.stdout循环走完就会自然退出,所以一般不会遗留线程。但如果是极端情况——比如任务对象被删除但子进程还在跑,读线程就会永远阻塞在 read 上。所以涉及任务删减操作时,必须先确认子进程已经终止,或者显式proc.kill()之后再删除。
4. 前端实现:异步轮询与渲染联动
后端接口就绪之后,前端反而是整个项目最影响“体感”的部分。我见过有人用 jQuery 的$.get加setInterval写出来的版本,功能是正常的,但体验粗糙:没有滚动锁定、按钮状态不重建、任务结束后定时器不清理。下面给出我调整过一轮的完整前端逻辑。
4.1 页面布局
页面布局尽量接近终端的效果:一个黑色的可滚动容器,里面一行行渲染日志,顶部有启动按钮和状态文字,右侧一个自动滚动开关。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>任务实时日志</title> <style> body { font-family: monospace; background: #1e1e1e; padding: 20px; } .log-container { background: #0d0d0d; color: #d4d4d4; height: 480px; overflow-y: auto; padding: 12px; border-radius: 6px; line-height: 1.6; font-size: 14px; white-space: pre-wrap; word-break: break-all; } .log-line { margin: 0; padding: 0; } .toolbar { margin-bottom: 12px; display: flex; gap: 12px; align-items: center; } .status { color: #aaa; } button { padding: 6px 18px; cursor: pointer; } button:disabled { opacity: 0.5; cursor: not-allowed; } </style> </head> <body> <div class="toolbar"> <button id="startBtn">启动任务</button> <button id="clearBtn">清空显示</button> <label> <input type="checkbox" id="autoScroll" checked> 自动滚动 </label> <span class="status" id="statusText">当前未运行</span> </div> <div class="log-container" id="logContainer"></div> <script src="main.js"></script> </body> </html>页面没有用框架,就是原生 HTML 加一个main.js,这样可以直接嵌入 Django 模板,也可以把页面静态化之后再单独部署。
4.2 fetch 轮询的核心逻辑
前端脚本最重要的是管理好几个状态变量的生命周期:taskId当前任务,offset已消费行数,pollTimer定时器句柄,stopped手动停止标志。启动任务时先 POST 拿到 task_id,拿到后立刻开始拉第一波日志,然后启动定时器。
let taskId = null; let offset = 0; let pollTimer = null; let stopped = false; const POLL_INTERVAL = 800; const logContainer = document.getElementById('logContainer'); const statusText = document.getElementById('statusText'); const autoScrollCb = document.getElementById('autoScroll'); async function startTask() { if (taskId) return; stopped = false; clearLog(); setStatus('正在启动任务...'); document.getElementById('startBtn').disabled = true; try { const form = new FormData(); form.append('script', '/path/to/your/script.py'); const resp = await fetch('/api/task/start/', { method: 'POST', body: form, credentials: 'same-origin', }); const data = await resp.json(); if (!resp.ok) { throw new Error(data.error || '启动失败'); } taskId = data.task_id; offset = 0; await pollLogs(); pollTimer = setInterval(pollLogs, POLL_INTERVAL); } catch (e) { setStatus('启动失败: ' + e.message); document.getElementById('startBtn').disabled = false; taskId = null; } } async function pollLogs() { if (!taskId || stopped) return; try { const resp = await fetch(`/api/task/${taskId}/logs/?offset=${offset}`, { credentials: 'same-origin', }); const data = await resp.json(); if (!resp.ok) { throw new Error(data.error || '拉取日志失败'); } appendLines(data.lines); offset = data.offset; if (data.finished) { stopPolling(); setStatus(`任务结束,退出码 ${data.exit_code}`); document.getElementById('startBtn').disabled = false; taskId = null; } else { setStatus('任务运行中...'); } } catch (e) { // 瞬时错误不要停轮询,下个周期再试 console.warn('日志轮询失败:', e.message); } } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } taskId = null; }appendLines负责把新增的行渲染到容器里,同时处理自动滚动:
function appendLines(lines) { if (!lines || !lines.length) return; const fragment = document.createDocumentFragment(); for (const [ts, text] of lines) { const div = document.createElement('div'); div.className = 'log-line'; const timeStr = new Date(ts * 1000).toLocaleTimeString(); div.textContent = `[${timeStr}] ${text}`; fragment.appendChild(div); } logContainer.appendChild(fragment); if (autoScrollCb.checked) { logContainer.scrollTop = logContainer.scrollHeight; } } function clearLog() { logContainer.innerHTML = ''; offset = 0; }这里用createDocumentFragment而不是逐条 append 到容器,性能差异在日志行数多的时候非常明显。一次轮询如果进来 200 行,直接循环 append 会触发 200 次 DOM 重排,浏览器会明显卡顿;用 Fragement 合并成一次插入,体感上流畅很多。
4.3 自动滚动与手动滚动
自动滚动是一个看起来不起眼、但做不好就很烦人的细节。如果日志一直自动滚到底部,用户想上翻看之前的内容就会被强行拽下来。这里我的处理方式是:只有自动滚动开关打开时才滚动到底,并且用户一旦手动把滚动条往上方拖动,就自动取消自动滚动。代码如下:
logContainer.addEventListener('scroll', () => { const distanceFromBottom = logContainer.scrollHeight - logContainer.scrollTop - logContainer.clientHeight; if (distanceFromBottom > 80) { autoScrollCb.checked = false; } else { autoScrollCb.checked = true; } });这样用户体验和终端工具的“跟随输出”行为一致,细节虽然小,但对长时间盯日志的人来说很关键。
4.4 任务结束与定时器清理
任务结束时的处理要非常小心,不能出现并发状态下重复清理的问题。比如pollLogs正在执行过程中,data.finished已经为 true,同时用户可能又点了一次启动按钮。如果不在startTask开头判断if (taskId) return;防止重复提交,一个页面就会拉起两个任务实例。
页面卸载时的清理同样重要,用户在任务进行中直接关闭浏览器标签页,前端定期器带不走,但全局 JS 上下文销毁后轮询自然停了。真正需要担心的是用户不关页面但切换路由(如果是单页应用),此时必须在路由钩子里调用stopPolling()。我做得比较保守,在页面beforeunload事件里也绑了一次清理,确保无论怎么退出都不会留下后台定时器。
5. 常见问题与排查技巧实录
写这种偏底层的方案,代码只是冰山一角,真正折磨人的是运行期间冒出来的各种环境性问题。我把实际用下来遇到最多的几个坑整理成了速查表,每一条都是见了血的教训。
| 现象 | 直接原因 | 解决方案 |
|---|---|---|
| 前端很久才看到第一行日志,甚至一直看不到 | Python 默认块缓冲导致输出攒在缓冲区 | 启动时加-u参数,设置环境变量PYTHONUNBUFFERED=1,或者在脚本里print(..., flush=True) |
| 中文日志变成乱码 | 子进程编码与 Django 进程编码不一致 | Popen(..., text=True, encoding='utf-8', errors='replace') |
| 任务已结束但前端还显示“运行中” | 进程退出了但管道里还有残留数据没读完 | 读线程用for line in proc.stdout循环确保读完所有行后再结束 |
| 进程被杀导致队列丢失全部日志 | 读线程随主进程一起结束 | 用独立进程跑任务,或用文件记录日志做持久化兜底 |
| gunicorn 多 worker 部署后日志抓不到 | 每个 worker 进程内存相互隔离,task_id 可能路由到另一个 worker | 生产环境改用 Redis 或数据库存储日志 |
| 页面后台标签页轮询停止,切回来日志有缺口 | 浏览器对后台标签页的 setInterval 自动节流 | 监听visibilitychange事件,切回时手动触发一次拉取 |
5.1 输出卡住了,迟迟看不到新日志
这是最经典的一个坑,而且很多人第一反应是代码写错了。其实问题出在 Python 的输出缓冲上。当子进程的 stdout 被重定向到管道而不是终端时,Python 解释器会自动改用块缓冲,缓冲区通常 8KB。也就是说,脚本 print 了 100 行“正在处理第 N 条数据”,如果这些文字没攒够 8KB,就不会从管道里吐出来,读线程自然读不到,前端一直等。只有等脚本结束,缓冲区强制刷新,所有日志才一次性送出来,表现得像是“页面卡了十分钟然后突然全冒出来”。
解决的办法就是我在代码里那两行:sys.executable -u参数和PYTHONUNBUFFERED=1。-u会让 Python 以无缓冲模式运行,所有 print 立即写出。同时,在脚本内部如果你控制不了启动命令,也可以在脚本顶部写sys.stdout.reconfigure(line_buffering=True),或者每个 print 加flush=True。但后招都不如-u来得彻底,因为你永远不知道脚本里面还会不会嵌套 subprocess 调用子进程。
5.2 中文乱码
日志全是英文没事,一旦脚本里 print 中文,前端这边经常看到一堆锟斤拷或者奇怪的字符。根本原因是子进程输出文本的编码和 Django 进程读取时指定的编码不一致。在 Linux 服务器上一般问题不大,因为大多数环境 UTF-8 是默认值;但在 Windows 开发机上,Python 的默认编码是 GBK,管道吐出来的字节流不符合 UTF-8 解码规则。
我的建议是把encoding和errors参数写死,不依赖系统默认值:
proc = subprocess.Popen( ..., text=True, encoding="utf-8", errors="replace", )另外errors="replace"是个隐藏细节。假如脚本中途莫名吐出来一段非法 UTF-8 字节,默认的strict模式会直接抛UnicodeDecodeError,把整个读线程搞崩;用replace的话,坏字节会被替换成占位符而不会中断任务,更稳妥。当然,最佳实践还是确保脚本内部统一用 UTF-8 输出,问题从源头就消灭掉。
5.3 任务结束但还有日志没显示完
这个坑我也踩过。proc.poll()判断进程已经结束了,但队列里的日志还没完全推完。原因是:子进程退出后,stdout 管道里可能还残留着最后一批数据,读线程的for line in proc.stdout循环还没把最后几行读进队列,前端就收到了finished=true,于是停止轮询,最后的日志就丢了。
解决办法是:在响应用finished=true之前,先确保读线程已经把管道读完。最直接的做法是让接口在处理完队列切片之后,拿到任务字典里的读线程对象,thread.join(timeout=1)再判断 finished。读线程在for line循环结束后会自然退出,join 一下能有效避免“抢跑”问题。当然更简单的兜底方案是前端收到 finished 之后延时 500ms 再发一次最终拉取,这也能解决问题,但不如后端 join 来得干净。我在代码里是在finished = proc.poll() is not None之前加了一步:
if proc.poll() is not None and task["thread"].is_alive(): task["thread"].join(timeout=1)5.4 多 worker 部署下全局字典失效
开发环境python manage.py runserver是单进程,所有请求都在同一个进程里处理,全局字典 TASKS 大家都能访问到。但一旦换成 gunicorn 或者 uWSGI 跑多个 worker 进程,问题就来了:启动任务的那个 worker 进程把 task_id 放进了自己的内存,但用户下一次轮询请求可能被负载均衡路由到另一个 worker,那个 worker 的 TASKS 字典里根本没有这个 task,直接返回 404。
这个问题在开发阶段完全不会暴露,上线才会炸。所以如果你的项目最终要用多进程方式部署,直接把日志缓存从进程内存挪到外部存储。有几个替代方案:
- 把日志行追加到文件,接口每次从文件读取 offset 之后的部分。
- 用 Redis 的 List 数据结构,按 task_id 建 key,用
RPUSH写日志、LRANGE按范围读。 - 用数据库表存日志,虽然查询慢一点但最可靠。
如果你只是在内部环境用单进程跑,全局字典完全够用,但这种陷阱必须写进注释里提醒后来维护的人。
5.5 安全问题与命令注入
subprocess启动命令行是有安全风险的。最忌讳的写法是拼一个完整的 shell 字符串丢给shell=True,比如直接拿用户输入的 script 路径去拼python xxx.py。如果用户传了类似abc.py; rm -rf /tmp的内容,在 shell 模式下就会变成两条命令执行。我的代码里用的是列表参数形式[sys.executable, "-u", script_path],这样不会经过 shell 解析,直接以参数形式传递给操作系统执行,注入风险大幅降低。
更进一步,script_path 必须经过白名单校验,不能是用户任意指定的路径。我实际项目里是限定只能从某个固定目录下选择脚本,接口收到的参数经过os.path.realpath解析后校验前缀,路径不存在或者越界直接拒绝。同时在start_task里加一个简单的并发上限,比如同时最多只能跑 3 个任务,防止有人恶意刷接口把服务器上所有进程资源耗尽。
6. 最后一些实操体会与扩展方向
这套方案我前后调整了三轮,第一版是最原始的“任务结束后一起输出”,第二版加了增量 offset,第三版才把线程清理和异常兜底补齐。做完之后的最大感受是:实时日志展示这种事情,技术栈选择不是越新越好,满足交互场景、部署简单、出错时容易排查才是核心。Ajax 轮询真正优势不在于性能表现,而在于当线上出问题的时候,我可以直接 curl 接口,手动控制 offset,一行一行把日志流从头看到尾,这种可调试性在复杂调试场景里是无价的。
如果你后续有更复杂的展示需求——比如多个任务的日志同时展示在一个页面上,就不太建议继续用我这种全局字典方案了。每个任务的日志应该独立存到 Redis Stream,前端可以同时开多个轮询,每个轮询维护各自的 task_id 和 offset,页面用多个容器分别渲染。再往后如果要做日志的关键字高亮、按级别过滤、历史归档,用文件或者 Redis 存储的方案迁移起来也更平滑。
还有一点想说的是关于轮询间隔的选择。我代码里写的是 800ms,实际操作中我一般会这样定:需要看脚本跑进度的场景,500ms 到 800ms 就够了;长时间的批处理任务,比如一小时级别,间隔放到 2 到 3 秒也完全没问题,还能大幅降低无效请求。间隔太短只是让服务端日志更吵,前端并不会因此显得更“实时”,因为脚本的 print 频率本身才是刷新率的瓶颈。
这套代码我没有做 Docker 化的额外适配,不过有一点提醒:如果你在容器里跑 Django,子进程读取必须考虑孤儿进程的情况——Django 主进程被 kill -9 的话,读线程和子进程可能都没人回收。所以更严谨的做法是给子进程一个父进程死亡检测,或者把子进程也交给系统的 init 进程托管。我目前项目里是加了一个定时器来扫描超过 10 分钟没有更新的子进程并主动 kill 掉,宁可任务失败也要避免僵尸进程占着 CPU 和内存。
最后分享一个我压箱底的小技巧:前端页面不要把 offset 存成全局变量就算完了,可以在调试阶段把document.title设置成当前日志行数,这样你切到后台标签页时,瞄一眼标题栏就能知道新日志有没有在进。这个小改动不需要额外拦截数据,就是在 appendLines 里加一行document.title = lines.length + ' 行新日志 - 任务运行中',对排查看板非常有用。你会发现,实际工作中很多体验上的小细节,都不是靠设计出来的,而是被问题磨出来的。