B/S 系统中的"长任务"该怎么设计?

写在前面

在 B/S 系统里,导出报表、批量处理、AI 分析、数据同步……这类"跑几秒到几分钟"的操作,如果还按照传统的"发请求 - 等响应"来做,几乎必然会遇到网关超时、页面卡死、用户重复点击等一堆问题。

这类操作我们统称为长任务(Long-Running Task)

本文梳理一套通用的设计模式,并且明确前后端各自需要关心的点——毕竟这是一个需要前后端配合才能做好的事情,任何一边掉链子,体验都会打折扣。

一、核心思路:别让 HTTP 连接干等

最核心的一条原则是:长任务绝不能用一个"傻等"的同步接口

前端 --请求--> 后端(同步执行 3 分钟)--响应--> 前端

原因很直接:Nginx、K8s Ingress、浏览器本身都有连接超时限制(通常 60s 左右),同步扛太久大概率会被中间层掐断连接,而且会长期占用一个处理线程/连接,并发一高就雪崩。

整个协作流程如下:

async-task-sequence-light@2x

参考: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 上要体现"取消中"这个中间态
}
posted @ 2026-08-09 10:25  talentzemin  阅读(10)  评论(0)    收藏  举报