资讯动态

Django实时日志系统:用AJAX轮询与subprocess实现Python任务输出实时展示

发布时间:2026/10/23 21:05:38 来源:尧图企业网站定制
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 轮询、SSEServer-Sent Events和 WebSocket。各有利弊但适用场景完全不同。特性Ajax 轮询SSEWebSocket实现复杂度低无需额外依赖中需要 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 的存在不会出现明显的数据竞争问题小额并发场景足够可靠。我还给它设置了maxlen2000超过 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 字段}, status400) if not os.path.isfile(script_path): return JsonResponse({error: 脚本文件不存在}, status400) task_id uuid.uuid4().hex[:8] buffer deque(maxlen2000) # 关键1PYTHONUNBUFFERED 强制子进程不缓冲输出 env os.environ.copy() env[PYTHONUNBUFFERED] 1 # 关键2stdout 和 stderr 合并到同一个管道 # 关键3textTrue 让管道以文本模式读取而不是字节 # 关键4bufsize1 表示行缓冲 proc subprocess.Popen( [sys.executable, -u, script_path], stdoutsubprocess.PIPE, stderrsubprocess.STDOUT, stdinsubprocess.DEVNULL, envenv, bufsize1, textTrue, encodingutf-8, errorsreplace, cwdos.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(targetreader, args(proc, buffer), daemonTrue) 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}, status404) 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_age7200): 清理已经结束且超过保留时限的任务避免内存无限增长 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 langzh-CN head meta charsetUTF-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 classtoolbar button idstartBtn启动任务/button button idclearBtn清空显示/button label input typecheckbox idautoScroll checked 自动滚动 /label span classstatus idstatusText当前未运行/span /div div classlog-container idlogContainer/div script srcmain.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参数设置环境变量PYTHONUNBUFFERED1或者在脚本里print(..., flushTrue)中文日志变成乱码子进程编码与 Django 进程编码不一致Popen(..., textTrue, encodingutf-8, errorsreplace)任务已结束但前端还显示“运行中”进程退出了但管道里还有残留数据没读完读线程用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参数和PYTHONUNBUFFERED1。-u会让 Python 以无缓冲模式运行所有 print 立即写出。同时在脚本内部如果你控制不了启动命令也可以在脚本顶部写sys.stdout.reconfigure(line_bufferingTrue)或者每个 print 加flushTrue。但后招都不如-u来得彻底因为你永远不知道脚本里面还会不会嵌套 subprocess 调用子进程。5.2 中文乱码日志全是英文没事一旦脚本里 print 中文前端这边经常看到一堆锟斤拷或者奇怪的字符。根本原因是子进程输出文本的编码和 Django 进程读取时指定的编码不一致。在 Linux 服务器上一般问题不大因为大多数环境 UTF-8 是默认值但在 Windows 开发机上Python 的默认编码是 GBK管道吐出来的字节流不符合 UTF-8 解码规则。我的建议是把encoding和errors参数写死不依赖系统默认值proc subprocess.Popen( ..., textTrue, encodingutf-8, errorsreplace, )另外errorsreplace是个隐藏细节。假如脚本中途莫名吐出来一段非法 UTF-8 字节默认的strict模式会直接抛UnicodeDecodeError把整个读线程搞崩用replace的话坏字节会被替换成占位符而不会中断任务更稳妥。当然最佳实践还是确保脚本内部统一用 UTF-8 输出问题从源头就消灭掉。5.3 任务结束但还有日志没显示完这个坑我也踩过。proc.poll()判断进程已经结束了但队列里的日志还没完全推完。原因是子进程退出后stdout 管道里可能还残留着最后一批数据读线程的for line in proc.stdout循环还没把最后几行读进队列前端就收到了finishedtrue于是停止轮询最后的日志就丢了。解决办法是在响应用finishedtrue之前先确保读线程已经把管道读完。最直接的做法是让接口在处理完队列切片之后拿到任务字典里的读线程对象thread.join(timeout1)再判断 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(timeout1)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 字符串丢给shellTrue比如直接拿用户输入的 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 行新日志 - 任务运行中对排查看板非常有用。你会发现实际工作中很多体验上的小细节都不是靠设计出来的而是被问题磨出来的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑