客诉工单 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