编程 客诉工单 SLA 计时的三处实现错误:工作日 deadline、暂存停表、重复告警

2026-11-01 00:05:38

客诉工单 SLA 计时的三处实现错误:工作日 deadline、暂存停表、重复告警

自建客诉工单系统第一版,SLA 只写了一行:deadline_at = created_at + INTERVAL sla_value HOUR。上线后暴露三个问题:

  • 周五下午提交、SLA 标称 2 天的工单,周一早上就被判超时,周末和节假日被算了进去;
  • 处理人点了暂存后,倒计时仍在走,暂存结束回到列表时工单已标红;
  • 扫描任务每 5 分钟跑一次,同一张超时工单的告警群消息刷了十几条。

这三个问题共享同一个原因:把“截止时刻”当成一个静态算式,把“计时”当成当前时间与截止时刻的比较。实际实现中,截止时刻要按日历推进,计时区间要能暂停,告警要幂等。下面按这三处分别说明。

语义口径参考阿里云客服工作台《工单》文档:模板里时效类型分工作日和自然日,时效要求填具体数字;暂存期间不超时;退回、加签、指派、申领会改变责任人和节点。文档给的是配置语义,实现层要自己翻译成表和算法。

一、工作日和自然日不能共用一个加法

判据:只要 sla_type = business_day,deadline 就必须落在工作日历上;自然日才允许直接加固定时长。用 created_at + N * 3600 处理工作日口径,错误率接近 100%,因为任何跨周末的工单都会提前超时。

先落一张日历表,按站点区分,节假日和调休都用这张表表达:

CREATE TABLE biz_calendar (
  site       VARCHAR(32) NOT NULL DEFAULT 'cn',
  cal_date   DATE        NOT NULL,
  is_workday TINYINT(1)  NOT NULL,
  day_type   ENUM('workday','weekend','holiday','makeup') NOT NULL,
  PRIMARY KEY (site, cal_date)
) ENGINE=InnoDB;

makeup 表示调休上班:is_workday = 1 但 day_type 不是 workday。判断逻辑只读 is_workday,day_type 用于排查和对账,不要在两个地方各自判断。

按天计时的取法:不要“加 N×24 小时”,而是找“第 N 个工作日的 17:00”。这里的 17:00 是业务约定的每日截止时刻,跟工作时段 9:00–18:00 是两个东西,别混用。

from datetime import datetime, time, timezone
from zoneinfo import ZoneInfo

TZ = ZoneInfo("Asia/Shanghai")
DAY_END = time(17, 0)

def business_day_deadline(created_utc: datetime, n: int, site: str = "cn") -> datetime:
    d = created_utc.astimezone(TZ)
    # 创建当天已过 17:00 或当天不是工作日,从下一个工作日开始计第 1 天
    if not is_workday(d.date(), site) or d.time() >= DAY_END:
        d = next_workday(d.date(), site)
    counted = 1
    while counted < n:
        d = next_workday(d.date(), site)
        counted += 1
    return datetime.combine(d.date(), DAY_END, TZ).astimezone(timezone.utc)

“创建当天算不算第 1 天”需要业务确认。上面按“当天 17:00 前提交算第 1 天”实现,如果口径是“次日为第 1 天”,改 counted 初值即可,但必须固定下来并写进模板说明。

按小时计时且工作日口径:需要把工作日切成工作时段窗口,逐窗口消耗剩余时长,而不是把 N 小时摊到自然时间上。

from datetime import datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo

TZ = ZoneInfo("Asia/Shanghai")
WORK_START, WORK_END = time(9, 0), time(18, 0)

def business_hours_deadline(start_utc: datetime, hours: float, site: str = "cn") -> datetime:
    cur = start_utc.astimezone(TZ)
    remaining = hours
    while remaining > 0:
        if not is_workday(cur.date(), site):
            cur = next_workday_open(cur.date(), site)   # 下一个工作日 9:00
            continue
        ws = datetime.combine(cur.date(), WORK_START, TZ)
        we = datetime.combine(cur.date(), WORK_END, TZ)
        cur = max(cur, ws)
        if cur >= we:
            cur = next_workday_open(cur.date(), site)
            continue
        avail = (we - cur).total_seconds() / 3600
        if avail >= remaining:
            return (cur + timedelta(hours=remaining)).astimezone(timezone.utc)
        remaining -= avail
        cur = next_workday_open(cur.date(), site)
    return cur.astimezone(timezone.utc)

跨时段的关键在 cur = max(cur, ws) 和“超出当前窗口的余量结转下一天”。循环次数等于跨越的日历天数,SLA 一般不超过几十天,这个开销可以接受,没必要预计算连续工作日段。

自然日口径直接加固定时长,但“1 个自然日”是 24 小时还是“次日同一时刻”,属于配置语义,要在模板上写清楚,并且落库时存的是算完的 UTC 时刻,不是原始小时数。

二、暂存、退回、待客户反馈:用暂停区间累减,不是改状态

暂存的语义是“在暂存时间之前不会超时”。实现上不要给工单打一个 is_paused 标记然后让扫描任务跳过——这样无法回答两个问题:已经消耗了多少时长、暂停结束时 deadline 应该往后推多少。

正确做法是建区间表,每次暂停一条记录,恢复时关闭区间:

CREATE TABLE ticket_pause (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  ticket_id     BIGINT UNSIGNED NOT NULL,
  reason        ENUM('hold','returned','wait_customer') NOT NULL,
  counts_pause  TINYINT(1) NOT NULL DEFAULT 1,
  started_at    DATETIME NOT NULL,          -- UTC
  planned_end_at DATETIME NULL,             -- 暂存到期时间,可空
  ended_at      DATETIME NULL,              -- 实际恢复时间
  applied       TINYINT(1) NOT NULL DEFAULT 0,
  open_ticket_id BIGINT UNSIGNED GENERATED ALWAYS AS
    (IF(ended_at IS NULL, ticket_id, NULL)) STORED,
  KEY idx_ticket (ticket_id, started_at),
  UNIQUE KEY uk_open (open_ticket_id)
) ENGINE=InnoDB;

open_ticket_id 这个生成列加唯一索引,保证同一工单最多一条未关闭区间。MySQL 唯一索引允许多个 NULL,所以已关闭的历史区间不受限制。没有这个约束,两次点击暂存会产生重叠区间,后面的 SUM 会重复扣减。

reason 区分三种暂停来源:暂存是主动挂起,退回是等提交人改单,待客户反馈是外部依赖。三者走同一套机制,是否真的停表由 counts_pause 决定——比如有些团队规定退回期间照常计时,只需把该 reason 的 counts_pause 置 0,代码不用改分支。

deadline_at 采用“恢复时写回”的方案:工单表同时保留不可变的 base_deadline_at 和可推进的 deadline_at。恢复动作在一个事务里完成:

START TRANSACTION;

SELECT id, started_at, ended_at
FROM ticket_pause
WHERE ticket_id = ? AND ended_at IS NOT NULL AND applied = 0 AND counts_pause = 1
FOR UPDATE;

-- 应用层求和后执行:
UPDATE ticket
SET deadline_at = DATE_ADD(deadline_at, INTERVAL ? SECOND),
    paused_seconds = paused_seconds + ?
WHERE id = ?;

UPDATE ticket_pause SET applied = 1
WHERE ticket_id = ? AND ended_at IS NOT NULL AND applied = 0 AND counts_pause = 1;

COMMIT;

用 applied 标记已计入的区间,避免重试或重复恢复把同一段时间扣两次。对账 SQL 可以随时发现漏算和重算:

SELECT t.id, t.base_deadline_at, t.deadline_at,
       DATE_ADD(t.base_deadline_at,
                INTERVAL COALESCE(SUM(TIMESTAMPDIFF(SECOND, p.started_at, p.ended_at)), 0) SECOND) AS expect_deadline
FROM ticket t
LEFT JOIN ticket_pause p
  ON p.ticket_id = t.id AND p.counts_pause = 1 AND p.ended_at IS NOT NULL
GROUP BY t.id, t.base_deadline_at, t.deadline_at
HAVING expect_deadline <> t.deadline_at;

如果 scan 任务走的是“实时算有效 deadline”,那就用不上 deadline_at 上的索引,所以这里选了写回。代价是工作日口径下的写回是近似的:17:00 加回 2 小时会落到 19:00,严格按工作日算应该顺延到次日 9:00。目前的折中是——只有 sla_type = business_day 的工单在恢复时重跑一遍工作日推进算法,其余直接加秒数。这部分还没有在全部模板上验证过,属于需要留意的点。

三、超时告警:用 (ticket_id, level) 唯一键幂等

扫描任务写错,通常长这样:

-- 反面写法
SELECT id FROM ticket WHERE deadline_at < NOW() AND alerted = 0;
-- 查出后逐条发送,再 UPDATE ticket SET alerted = 1

两个问题。第一,deadline_at < NOW() 在下一轮扫描依然为真,只要状态标记没写成功或者中途异常,同一张单会被反复捞出来。第二,先查后写在多实例下存在并发窗口,两个 worker 同时查到同一条,两条告警都发出去。

改成独立告警表,用唯一键做幂等:

CREATE TABLE ticket_alert (
  id        BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  ticket_id BIGINT UNSIGNED NOT NULL,
  level     ENUM('warning','overdue','breach') NOT NULL,
  fired_at  DATETIME NOT NULL,
  UNIQUE KEY uk_ticket_level (ticket_id, level)
) ENGINE=InnoDB;

level 是超时级别,不是重复次数:临期提醒、已超时、超时超过 24 小时各算一级,每级只发一次。发送前先抢占:

INSERT INTO ticket_alert (ticket_id, level, fired_at)
VALUES (?, ?, UTC_TIMESTAMP())
ON DUPLICATE KEY UPDATE id = id;

ROW_COUNT() = 1 表示这次真的插入成功,才真正发消息;返回 0 说明该工单该级别已经发过,直接跳过。用 ON DUPLICATE KEY UPDATE id = id 而不是 INSERT IGNORE,后者会把其他类型的错误也一起吞掉。

扫描本身用时间窗口加 SKIP LOCKED,避免多实例抢同一批行:

SELECT id, deadline_at
FROM ticket
WHERE status IN ('pending','processing')
  AND deadline_at < DATE_ADD(UTC_TIMESTAMP(), INTERVAL 2 HOUR)
ORDER BY deadline_at
LIMIT 200
FOR UPDATE SKIP LOCKED;

这条语句必须在短事务里执行,取到 id 后立即提交,不要在持锁期间调用告警通道。如果 MySQL 版本低于 8.0 不支持 SKIP LOCKED,退化成按 id 分页加唯一键兜底,幂等仍然成立,只是会有无效扫描。

工单表结构大致如下,deadline_at 落库并建索引:

CREATE TABLE ticket (
  id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  site            VARCHAR(32) NOT NULL DEFAULT 'cn',
  status          VARCHAR(16) NOT NULL,
  created_at      DATETIME NOT NULL,
  sla_type        ENUM('natural_day','business_day') NOT NULL,
  sla_unit        ENUM('day','hour') NOT NULL DEFAULT 'day',
  sla_value       INT NOT NULL,
  base_deadline_at DATETIME NOT NULL,
  deadline_at     DATETIME NOT NULL,
  paused_seconds  INT NOT NULL DEFAULT 0,
  KEY idx_scan (status, deadline_at)
) ENGINE=InnoDB;

(status, deadline_at) 这个联合索引是给扫描用的,前缀 status 的选择性不高但能过滤掉已关闭工单,后面按 deadline_at 范围扫。

四、几个容易踩的实现细节

统一 UTC 存储。created_at、deadline_at、started_at、ended_at 全部存 UTC DATETIME,应用层负责和 Asia/Shanghai 互转。不要存本地时间再靠时区换算补回来,一旦有一条数据混了时区,对账时就只能靠猜。

不要用 NOW()。NOW() 返回会话时区的时间,连接池里不同连接的 time_zone 未必一致,同一个查询在不同实例上结果可能差 8 小时。统一用 UTC_TIMESTAMP(),或者由应用层传入时间参数。

变量命名区分 “工作日判断”和“展示时间”。判断永远走 is_workday 字段和站点时区,展示才做本地化格式化,两者不要共用同一个函数。

deadline_at 必须落列。每次查询实时算 deadline,意味着扫描任务要全表扫描再在应用层过滤,工单量上去之后完全不可用,也无法建索引。

五、适用边界

这套做法面向单站点、单一时区的客诉工单。以下情况需要额外处理:

  • 跨时区:biz_calendar 要按 site 各存一份,deadline_at 的推进也要用对应站点的时区,等于把上面的算法按站点各跑一遍;
  • 法定节假日每年调整:日历表要能导入新年度的数据,还要处理“已生成 deadline 的在途工单是否需要按新日历重算”,目前是靠重跑 deadline 计算任务,没有做增量迁移;
  • SLA 承诺写进合同:合同里通常有免责条款、按小时分级赔付、争议时的人工核定,这些都超出自动计时的范围,系统算出来的只能作为参考值。

暂存到期是自动关闭区间还是等人工恢复,也还没定。现在按 planned_end_at 到期自动关闭处理,如果业务上希望“暂存到期后仍然停表直到有人处理”,把自动关闭去掉即可,表结构不用动。

信源:阿里云客服工作台《工单》 https://help.aliyun.com/zh/document_detail/421478.html

复制全文 生成海报 客诉处理 工单系统 MySQL SLA 运维

推荐文章

程序员茄子在线接单