B/S 系统中的"长任务"该怎么设计?
写在前面
在 B/S 系统里,导出报表、批量处理、AI 分析、数据同步……这类"跑几秒到几分钟"的操作,如果还按照传统的"发请求 - 等响应"来做,几乎必然会遇到网关超时、页面卡死、用户重复点击等一堆问题。
这类操作我们统称为长任务(Long-Running Task)。
本文梳理一套通用的设计模式,并且明确前后端各自需要关心的点——毕竟这是一个需要前后端配合才能做好的事情,任何一边掉链子,体验都会打折扣。
一、核心思路:别让 HTTP 连接干等
最核心的一条原则是:长任务绝不能用一个"傻等"的同步接口。
前端 --请求--> 后端(同步执行 3 分钟)--响应--> 前端
原因很直接:Nginx、K8s Ingress、浏览器本身都有连接超时限制(通常 60s 左右),同步扛太久大概率会被中间层掐断连接,而且会长期占用一个处理线程/连接,并发一高就雪崩。
整个协作流程如下:

参考:https://learn.microsoft.com/en-us/azure/architecture/patterns/asynchronous-request-reply
下面分别说前后端各自要做什么。
二、后端要考虑的点
1. 任务提交接口:只做校验,立刻返回
接口职责很单一:校验参数、生成 taskId、把任务丢进队列,然后马上返回,不要在这个请求里等任务跑完。
[HttpPost("tasks")]
public async Task<IActionResult> SubmitTask([FromBody] CreateTaskRequest req)
{
// 1. 参数校验
if (!IsValid(req)) return BadRequest();
// 2. 幂等性检查:同一用户+同一业务参数,避免重复提交
var idempotentKey = $"task:idempotent:{userId}:{req.GetBusinessHash()}";
var existingTaskId = await _redis.StringGetAsync(idempotentKey);
if (existingTaskId.HasValue)
return Accepted(new { taskId = existingTaskId.ToString() });
// 3. 生成 taskId(用 Guid,不要用自增 ID,防止被枚举)
var taskId = Guid.NewGuid().ToString();
// 4. 初始状态写入 Redis(多副本部署下必须用 Redis/DB,不能用内存字典)
await _taskStateStore.SetAsync(taskId, new TaskState
{
Status = TaskStatus.Pending,
Progress = 0,
UserId = userId,
CreatedAt = DateTime.UtcNow
});
await _redis.StringSetAsync(idempotentKey, taskId, TimeSpan.FromMinutes(5));
// 5. 任务入队(Channel / RabbitMQ / Hangfire,任选其一)
await _taskQueue.EnqueueAsync(new LongTaskMessage(taskId, req));
return Accepted(new { taskId });
}
2. 状态存储:必须是"跨实例可见"的
要考虑集群环境下的多副本部署,用户轮询状态的请求,很可能被负载均衡路由到跟实际执行任务不是同一个 Instance。
如果状态存在内存字典里,查询方永远查不到。
- 状态必须放 Redis(查询快,支持 TTL 自动过期)
- 建议同时落一份到 数据库(用于任务历史、审计、失败重跑,Redis 数据丢了也有兜底)
public class TaskState
{
public string TaskId { get; set; }
public TaskStatus Status { get; set; } // Pending/Running/Succeeded/Failed/Cancelled
public int Progress { get; set; } // 0-100
public string Message { get; set; } // "正在校验数据…" 这类阶段性描述
public string? ResultUrl { get; set; } // 产出文件的 MinIO 地址
public string? ErrorMessage { get; set; }
public string UserId { get; set; } // 用于权限校验
}
3. 后台执行方式
可以直接在进程内执行,也可以存入队列。
我们以 Channel + BackgroundService 为例:
public class LongTaskWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in _channel.Reader.ReadAllAsync(stoppingToken))
{
// 每个任务注册一个独立的 CancellationTokenSource
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
_cancelRegistry.Register(msg.TaskId, cts);
try
{
await _taskStateStore.UpdateAsync(msg.TaskId, s => s.Status = TaskStatus.Running);
await RunTaskAsync(msg, cts.Token);
await _taskStateStore.UpdateAsync(msg.TaskId, s => s.Status = TaskStatus.Succeeded);
}
catch (OperationCanceledException)
{
await _taskStateStore.UpdateAsync(msg.TaskId, s => s.Status = TaskStatus.Cancelled);
}
catch (Exception ex)
{
await _taskStateStore.UpdateAsync(msg.TaskId, s =>
{
s.Status = TaskStatus.Failed;
s.ErrorMessage = ex.Message; // 给前端可读的错误,不要只说"失败了"
});
}
finally
{
_cancelRegistry.Remove(msg.TaskId);
}
}
}
private async Task RunTaskAsync(LongTaskMessage msg, CancellationToken token)
{
for (int i = 0; i < steps.Count; i++)
{
token.ThrowIfCancellationRequested(); // 关键节点主动检查取消
await DoStep(steps[i]);
var progress = (i + 1) * 100 / steps.Count;
await _taskStateStore.UpdateAsync(msg.TaskId, s =>
{
s.Progress = progress;
s.Message = $"正在执行:{steps[i].Name}";
});
}
}
}
取消粒度:ThrowIfCancellationRequested() 要放在循环体、每个子步骤开始前、每次 IO 调用前,不要指望一个粗粒度的检查能及时响应取消。
4. 取消:跨Instance也要生效
用户点取消时,请求可能落在 Instance A,但任务实际在 Instance B 上跑。单纯在内存里维护 CancellationTokenSource 是不够的,需要 Redis Pub/Sub 广播:
[HttpPost("tasks/{taskId}/cancel")]
public async Task<IActionResult> CancelTask(string taskId)
{
var task = await _taskStateStore.GetAsync(taskId);
if (task.UserId != CurrentUserId) return Forbid(); // 权限校验,防止取消别人的任务
await _taskStateStore.UpdateAsync(taskId, s => s.Status = TaskStatus.CancelRequested);
await _redis.PublishAsync("task:cancel", taskId); // 广播给所有 Pod
return Accepted();
}
// 每个 Pod 都订阅这个频道
_redis.Subscribe("task:cancel", (channel, taskId) =>
{
if (_cancelRegistry.TryGet(taskId, out var cts))
cts.Cancel(); // 只有真正在跑这个任务的 Pod 会命中
});
5. 并发保护 & 幂等 & 超时
- 用
SemaphoreSlim或 worker 数量限制同时运行的任务数,防止打爆线程池/DB 连接 - 幂等性用 Redis 唯一键防重复提交(上面代码已体现)
- 给任务设最大执行时长,超时自动标记失败并释放资源,避免"僵尸任务"
6. 结果与安全
- 任务产出如果是文件,传到 MinIO,任务状态里只存 URL,不要把大文件塞进 Redis
- 查询状态、取消任务的接口都要校验"当前用户是否是任务所有者",防止越权枚举他人任务
三、前端要考虑的点
1. 提交任务后立即给反馈,别让按钮"假死"
async function submitTask(payload) {
submitBtn.disabled = true; // 防止重复点击(但后端幂等才是真正防线)
try {
const res = await fetch('/api/tasks', {
method: 'POST',
body: JSON.stringify(payload),
});
const { taskId } = await res.json();
// taskId 存起来,支持刷新页面/离开再回来后还能查到
localStorage.setItem('currentTaskId', taskId);
startPolling(taskId);
} finally {
submitBtn.disabled = false;
}
}
2. 轮询 / SSE:按场景选
- 轮询:实现简单,适合大多数几秒到几十秒的任务
- SSE:服务器主动推送,比轮询实时,单向推送场景更合适,且比 WebSocket 轻量
- 耗时长、需要双向通信(比如前端还要发送指令)才考虑 SignalR/WebSocket
轮询实现要注意间隔策略和页面不可见时暂停:
function startPolling(taskId) {
let interval = 1000; // 初始 1s,可以做指数退避降低请求量
let timer = null;
async function poll() {
const res = await fetch(`/api/tasks/${taskId}`);
const state = await res.json();
updateProgressUI(state.progress, state.message);
if (state.status === 'Succeeded') {
onTaskDone(state.resultUrl);
return; // 终态,停止轮询
}
if (state.status === 'Failed') {
onTaskFailed(state.errorMessage); // 给用户看得懂的错误信息,别只显示"失败"
return;
}
if (state.status === 'Cancelled') {
onTaskCancelled();
return;
}
interval = Math.min(interval * 1.2, 5000); // 逐渐拉长轮询间隔,封顶 5s
timer = setTimeout(poll, interval);
}
// 页面切到后台时暂停轮询,回来时立刻补一次查询,节省资源
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearTimeout(timer);
} else {
poll();
}
});
poll();
}
SSE 版本会更简洁(如果后端支持):
function subscribeTask(taskId) {
const es = new EventSource(`/api/tasks/${taskId}/stream`);
es.onmessage = (e) => {
const state = JSON.parse(e.data);
updateProgressUI(state.progress, state.message);
if (['Succeeded', 'Failed', 'Cancelled'].includes(state.status)) {
es.close();
handleFinalState(state);
}
};
es.onerror = () => {
es.close();
// 降级为轮询,或提示用户网络异常
};
}
3. 页面离开不等于任务取消
用户关闭页面/切走标签页,任务不应该被强行终止(除非业务确实要求"没人看就别跑")。做法是:
- taskId 存
localStorage或放进 URL 参数 - 页面重新打开时,先检查是否有未完成的任务,有的话直接恢复轮询/订阅,而不是让用户重新提交
window.addEventListener('load', () => {
const taskId = localStorage.getItem('currentTaskId');
if (taskId) {
checkTaskStatus(taskId).then((state) => {
if (state.status === 'Running' || state.status === 'Pending') {
startPolling(taskId); // 恢复现场
} else {
localStorage.removeItem('currentTaskId');
}
});
}
});
4. 进度条要诚实
如果后端能算出精确进度(比如"第几步/共几步"),就展示真实百分比;如果算不出来,用阶段性文案代替假进度条,比如"正在校验数据…" → "正在生成报表…",比一个卡在 90% 不动的假进度条体验诚实得多。
5. 取消按钮的语义要想清楚
async function cancelTask(taskId) {
// 如果任务已经执行了一部分,取消可能导致部分数据已经产生
// 需要跟后端确认:取消是"真正回滚"还是"只是不再等待结果"
const confirmed = await confirm('任务正在执行,确定要取消吗?部分操作可能已生效。');
if (!confirmed) return;
await fetch(`/api/tasks/${taskId}/cancel`, { method: 'POST' });
updateUI('取消请求已发送,等待后端确认…');
// 取消不是立刻生效的,后端检查取消信号需要时间,UI 上要体现"取消中"这个中间态
}

浙公网安备 33010602011771号