
运维做久了会碰到一类特别别扭的复盘:不是排障多复杂,也不是团队能力有问题。把时间线一拉,真正在排查的时间不长,可MTTR就是压不下来。
一次典型的案例:工作日下午,核心业务系统响应变慢。告警14:03触发,14:11开始介入,14:39定位到数据库连接池耗尽,14:43处理完。排查加处理28分钟,不算慢。
但MTTR算出来是137分钟。
因为业务受影响实际从12:26就开始了——中间告警没触发,有将近两小时空窗。加上后面的确认、核实、关单,时间拖到了16:23。
把时间线拆成段分析,发现时间主要耗在三个地方。每个都不是技术问题,都是流程断点。
断点一:现场信息失真,排查方向跑偏15分钟
故障刚触发时,服务台传到运维侧的描述是"系统响应慢,可能是网络问题"。工程师拿到这句话,先看网络——出口带宽、核心交换延迟,一通扫下来都正常。又看应用服务器的CPU和内存,也没异常。花了15分钟才绕回来发现是数据库连接池的事。
后来翻原始记录,用户原话有一句"登录进去就卡,不只是打开慢"。这句话如果完整传到工程师手里,第一判断方向大概率直接奔应用层或数据库。
但服务台受理时,把"响应慢"默认关联到了"网络",无意间过滤掉了关键信息。
这种信息损耗不是个例。 用户描述的是现象,监控反映的是指标变化,服务台转述时又做一次筛选。三个来源各有损耗,拼到工程师手里经常是偏的。
解决:服务台受理加3个必填字段
- 哪一步卡住了——登录卡、查询卡、还是提交卡
- 影响谁——哪些用户、哪块业务
- 什么时候开始的——之前有没有类似情况
就这三条,加上以后工程师拿到工单时的第一判断命中率明显高了。
更进一步,如果把报障入口从电话/群聊统一到多渠道标准模板(邮件、企微、移动端都走同一套字段),翻译环节没了,信息失真就少了一大截。
断点二:工单挂着"处理中",实际半小时没人动
这是MTTR里最隐蔽的时间黑洞。
值班时扫一眼工单列表,状态写着"处理中",觉得有人在跟。实际那张单已经停在那里了。
跨组协作时特别多:A组排完自己这段,转给B组。B组发现还需要A组信息,在内部问了一句,然后去处理别的了。A组没注意到。工单状态一直挂着"转派-处理中",表面上没问题,但实际没有人在推了。
工单系统通常只管"有没有人接",不管"接了之后有没有在动"。这个差别平时看不出来,到跨组故障就全暴露了。
解决:加两条超时规则
第一条:接单超时。 高优先级10分钟、普通30分钟内无人接单,自动推提醒。这条大多数团队都有。
第二条(关键):推进停滞提醒。 工单在"处理中"状态超过一定时间没有任何更新(没新备注、没状态变化、没转派动作),系统提醒当前处理人补充进展。再超时一轮还是没动静,直接升级通知值班负责人。
很多团队只做了第一条没有第二条。单子接了,然后停了半小时一小时无人知道。这段"假处理"的时间全部算进了MTTR。
重要细节:超时提醒必须推到人真正看的地方——企微/钉钉/短信,不是弹在系统后台。处理人收到"你的单子停了",值班负责人收到"有一张高优先级单在卡"。推到手机上和推到后台里,差别非常大。
断点三:业务侧早就感知到了,运维两小时后才知道
这是最让人后怕的一段。
业务12:26开始受影响,运维14:11才介入。中间将近两小时。
翻群聊记录,12:50运营同事就在群里说了"系统有点慢,是不是在维护"。没人接话。13:30又有人在另一个群说"客户反馈系统卡"。有人回了句"我看下",但没有转成工单或通知值班。
一直等到14:03监控告警触发,运维才正式介入。
根本问题:升级路径全靠人判断。 业务的人觉得"可能不是大事",运维的人没在那个群里。信号在组织边界上消失了。
解决:两件事
1. 工单时效自动升级。 高优先级超时未处理的,系统直接升级通知值班负责人。不靠人判断要不要上报。
2. 给业务侧一个正式的上报入口。 不是群聊里喊一嗓子,是一个有记录的渠道——工单系统快速报障入口、或飞书/钉钉服务机器人。核心就一条:业务侧感知到的异常信号不能停在群聊里消失,要能进入运维的接收队列。
还有一层更根本的:加一层端到端业务品质探测。定时从用户视角跑关键操作——页面加载时间、接口响应时间、DNS解析速度。如果当时有这层探测在跑,12:26业务开始变慢时探测数据就已经在走高了,不用等设备指标触线才出告警。
三个断点,同一个问题
表面看是三件事,底层是同一个问题:信息和时效在流转过程中出了岔子。
- 第一个断点:信息在传递链路上失真了
- 第二个:时间在流程节点上停滞了
- 第三个:信号在组织边界上断掉了
MTTR不是一个人或一个环节的事,它是一条链。任意一环断了,时间就在那里耗着。
落地实现:工单停滞自动检测 + 钉钉推送
第二个断点是最容易用技术手段解决的。以下是一个完整的工单停滞检测脚本,配合crontab每5分钟跑一次,检查“处理中”状态超时未更新的工单:
#!/usr/bin/env python3
"""
工单停滞自动检测 + 钉钉通知
功能:扫描所有“处理中”工单,检测是否超时未更新
部署:crontab -e → */5 * * * * python3 /opt/scripts/ticket_stall_checker.py
依赖:pip install requests pymysql
"""
import pymysql
import requests
import json
from datetime import datetime, timedelta
# ============ 配置区 ============
DB_CONFIG = {
"host": "127.0.0.1",
"port": 3306,
"user": "readonly",
"password": "changeme", # 生产环境用环境变量
"database": "itsm",
"charset": "utf8mb4"
}
# 钉钉机器人Webhook
DINGTALK_WEBHOOK = "https://oapi.dingtalk.com/robot/send?access_token=xxx"
# 超时规则(分钟)
STALL_RULES = {
"P1": {
"warn": 10, "escalate": 20}, # P1: 10分钟提醒,20分钟升级
"P2": {
"warn": 30, "escalate": 60}, # P2: 30分钟提醒,1小时升级
"P3": {
"warn": 120, "escalate": 240}, # P3: 2小时提醒,4小时升级
}
# 值班负责人手机号(升级时@)
ONCALL_MANAGER_PHONE = "13800000001"
# ================================
def get_stalled_tickets():
"""查询所有“处理中”且超时未更新的工单"""
conn = pymysql.connect(**DB_CONFIG)
try:
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
# 查询处理中的工单,last_update_time是最后一次状态变更/备注时间
sql = """
SELECT
t.ticket_id,
t.title,
t.priority,
t.assigned_to,
t.status,
t.last_update_time,
t.created_at,
TIMESTAMPDIFF(MINUTE, t.last_update_time, NOW()) AS stall_minutes
FROM tickets t
WHERE t.status = 'processing'
AND t.priority IN ('P1', 'P2', 'P3')
ORDER BY t.priority ASC, stall_minutes DESC
"""
cursor.execute(sql)
tickets = cursor.fetchall()
finally:
conn.close()
return tickets
def check_stall(tickets):
"""检查每张工单是否超过停滞阈值"""
warn_list = [] # 需要提醒处理人的
escalate_list = [] # 需要升级的
for ticket in tickets:
priority = ticket["priority"]
stall_min = ticket["stall_minutes"]
rule = STALL_RULES.get(priority)
if not rule:
continue
if stall_min >= rule["escalate"]:
escalate_list.append(ticket)
elif stall_min >= rule["warn"]:
warn_list.append(ticket)
return warn_list, escalate_list
def send_dingtalk(message, at_phones=None):
"""发送钉钉机器人通知"""
payload = {
"msgtype": "markdown",
"markdown": {
"title": "工单停滞提醒",
"text": message
}
}
if at_phones:
payload["at"] = {
"atMobiles": at_phones, "isAtAll": False}
resp = requests.post(DINGTALK_WEBHOOK, json=payload, timeout=10)
return resp.status_code == 200
def format_warn_message(tickets):
"""格式化提醒消息"""
lines = ["### ⚠️ 工单停滞提醒\n"]
lines.append("以下工单在“处理中”状态且超时未更新:\n")
for t in tickets:
lines.append(
f"- **[{t['priority']}]** {t['title']}\n"
f" 处理人:{t['assigned_to']} | "
f"停滞:{t['stall_minutes']}分钟 | "
f"工单号:{t['ticket_id']}\n"
)
lines.append("\n> 请及时更新处理进展,或转派给其他同事")
return "\n".join(lines)
def format_escalate_message(tickets):
"""格式化升级消息"""
lines = ["### 🔴 工单停滞升级\n"]
lines.append("以下工单长时间无进展,已自动升级:\n")
for t in tickets:
lines.append(
f"- **[{t['priority']}]** {t['title']}\n"
f" 处理人:{t['assigned_to']} | "
f"停滞:{t['stall_minutes']}分钟 | "
f"工单号:{t['ticket_id']}\n"
)
lines.append(f"\n> @{ONCALL_MANAGER_PHONE} 请值班负责人介入处理")
return "\n".join(lines)
if __name__ == "__main__":
tickets = get_stalled_tickets()
warn_list, escalate_list = check_stall(tickets)
if warn_list:
msg = format_warn_message(warn_list)
send_dingtalk(msg)
print(f"[提醒] 发送{len(warn_list)}条停滞提醒")
if escalate_list:
msg = format_escalate_message(escalate_list)
send_dingtalk(msg, at_phones=[ONCALL_MANAGER_PHONE])
print(f"[升级] 发送{len(escalate_list)}条升级通知")
if not warn_list and not escalate_list:
print("[正常] 无停滞工单")
部署步骤:
# 1. 安装依赖
pip install requests pymysql
# 2. 修改配置(数据库连接、钉钉Webhook、超时规则)
vim /opt/scripts/ticket_stall_checker.py
# 3. 加入crontab,每5分钟跑一次
crontab -e
# 添加:*/5 * * * * /usr/bin/python3 /opt/scripts/ticket_stall_checker.py >> /var/log/stall_check.log 2>&1
# 4. 验证:手动跑一次看是否正常
python3 /opt/scripts/ticket_stall_checker.py
关键设计点:
- 超时规则按优先级分开:P1工单停10分钟就提醒,P3工单容忍2小时。不是所有工单一个标准
- 两级触发:第一次超时只提醒处理人,第二次超时升级通知值班负责人。避免一上来就惊动上级
- 推到手机不推到后台:钉钉/企微机器人直接推送,不是弹在工单系统后台等人去看
落地实现:MTTR各阶段自动拆解
要知道MTTR到底耗在哪,光看“总时长”没用。需要把每次故障的时间线自动拆成段:
def calculate_mttr_breakdown(ticket):
"""
把一张工单的MTTR拆解为各阶段耗时
工单状态流转时间点(从工单系统日志里取):
- impact_start: 业务实际受影响时间(事后确认)
- alert_fired: 告警触发时间
- engineer_start: 工程师开始处理时间(接单)
- root_cause_found: 定位根因时间
- resolved: 故障恢复时间
- ticket_closed: 工单关单时间
"""
breakdown = {
# 无人感知期:业务已受影响但还没人知道
"detection_gap": (ticket["alert_fired"] - ticket["impact_start"]).total_seconds() / 60,
# 响应延迟:告警出来到有人接手
"response_delay": (ticket["engineer_start"] - ticket["alert_fired"]).total_seconds() / 60,
# 排查时间:工程师从开始到定位根因
"diagnosis_time": (ticket["root_cause_found"] - ticket["engineer_start"]).total_seconds() / 60,
# 修复时间:定位后到实际恢复
"fix_time": (ticket["resolved"] - ticket["root_cause_found"]).total_seconds() / 60,
# 关单尾巴:恢复后到工单关闭
"close_delay": (ticket["ticket_closed"] - ticket["resolved"]).total_seconds() / 60,
}
breakdown["total_mttr"] = sum(breakdown.values())
breakdown["actual_work"] = breakdown["diagnosis_time"] + breakdown["fix_time"]
breakdown["process_waste"] = breakdown["total_mttr"] - breakdown["actual_work"]
breakdown["efficiency_ratio"] = breakdown["actual_work"] / breakdown["total_mttr"] * 100
return breakdown
def monthly_mttr_report(tickets_this_month):
"""月度MTTR拆解报告:看时间主要浪费在哪个阶段"""
totals = {
"detection_gap": 0, "response_delay": 0,
"diagnosis_time": 0, "fix_time": 0, "close_delay": 0}
count = len(tickets_this_month)
for ticket in tickets_this_month:
bd = calculate_mttr_breakdown(ticket)
for key in totals:
totals[key] += bd[key]
# 输出平均值
print(f"\n=== 本月MTTR拆解报告({count}次故障)===")
print(f"无人感知期(平均): {totals['detection_gap']/count:.1f} 分钟")
print(f"响应延迟(平均): {totals['response_delay']/count:.1f} 分钟")
print(f"排查时间(平均): {totals['diagnosis_time']/count:.1f} 分钟")
print(f"修复时间(平均): {totals['fix_time']/count:.1f} 分钟")
print(f"关单尾巴(平均): {totals['close_delay']/count:.1f} 分钟")
print(f"---")
avg_mttr = sum(totals.values()) / count
avg_work = (totals['diagnosis_time'] + totals['fix_time']) / count
print(f"平均MTTR: {avg_mttr:.1f} 分钟")
print(f"平均实际工作时间: {avg_work:.1f} 分钟")
print(f"流程浪费占比: {(1 - avg_work/avg_mttr)*100:.0f}%")
print(f"\n→ 最大瓶颈: ", end="")
max_phase = max(totals, key=totals.get)
phase_names = {
"detection_gap": "无人感知期(需加业务探测)",
"response_delay": "响应延迟(需优化接单机制)",
"diagnosis_time": "排查时间(需优化信息传递)",
"fix_time": "修复时间(需预案库/自动化)",
"close_delay": "关单尾巴(需简化确认流程)"
}
print(phase_names.get(max_phase, max_phase))
这个报告跑出来长这样:
=== 本月MTTR拆解报告(12次故障)===
无人感知期(平均): 43.2 分钟
响应延迟(平均): 8.5 分钟
排查时间(平均): 18.3 分钟
修复时间(平均): 6.1 分钟
关单尾巴(平均): 22.4 分钟
---
平均MTTR: 98.5 分钟
平均实际工作时间: 24.4 分钟
流程浪费占比: 75%
→ 最大瓶颈: 无人感知期(需加业务探测)
看到这个数据才能知道该优化哪里。 75%的时间不是花在排查和修复上,而是花在“没人知道”和“知道了但没人动”上。催工程师“排快点”根本没用。
落地效果:一个团队的前后对比
按上面的顺序落地(停滞提醒 → 受理字段标准化 → 业务探测),一个15人的运维团队3个月的数据变化:
| 指标 | 改进前 | 第1月后 | 第3月后 |
|---|---|---|---|
| 平均MTTR | 142分钟 | 98分钟 | 61分钟 |
| 流程浪费占比 | 78% | 62% | 41% |
| P1工单停滞>20分钟次数 | 6次/月 | 2次/月 | 0次/月 |
| 无人感知期(平均) | 47分钟 | 31分钟 | 8分钟 |
| 排查方向正确率 | 61% | 79% | 88% |
第1个月就见效的是“停滞提醒”——配一条超时规则,当天生效。第3个月见效的是“业务探测”——无人感知期从47分钟压到8分钟,这是MTTR从142降到61的最大贡献。
建议的改进顺序
如果你现在也觉得MTTR不太对劲,建议按这个优先级:
1. 先加推进停滞提醒(成本最低)。 配一条超时规则,十分钟就能完成。通知走企微/钉钉推到手机上,立竿见影。
2. 然后标准化服务台的受理字段。 改的是表单不是人的习惯,推广阻力不大。三个必填字段加上去,排查方向偏的概率就能压下来。
3. 最后推业务侧上报通道+品质探测。 推进周期最长,但砍掉的是MTTR里最大的那一段"无人感知期"。前两步见效后拿着数据去推,阻力会小很多。
小结
MTTR降不下来,很多时候不是排障不够快,是排障之外的时间没有被当成流程问题来管。信息失真、流程停滞、信号断档——找出这几个断点来治,比催工程师"排快点"有用得多。