Administrator
发布于 2022-01-31 / 6276 阅读
123

MySQL 死锁监控与自动告警建设

「下单偶尔失败,刷新一下就好了」

1 月中旬,运营那边反馈:商家后台点"确认收货"有时候会失败,弹一个系统繁忙,再点一次就好了。我们查日志,一天里有 200 多条这样的错:

com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
  Deadlock found when trying to get lock; try restarting transaction
  ; SQL [UPDATE t_order_item SET status = ? WHERE order_id = ? AND sku_id = ?]
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:124)
    at com.xxx.order.service.OrderService.confirm(OrderService.java:216)

MySQL 的死锁跟 Java 的死锁不一样:它会被自动检测并回滚其中一个事务,不会卡死。所以现象是"偶发失败"而不是"服务不可用",很容易被当成小问题放着。但一天 200 多次就不小了,而且我们当时完全没有死锁的监控,只能等业务方来报。

这篇文章记的是我们怎么把死锁的采集、告警、规避这三件事建起来。MySQL 8.0.27,一主两从。

第一步:拿到死锁的详细信息

SHOW ENGINE INNODB STATUS 只能看最后一次

mysql> SHOW ENGINE INNODB STATUS\G
...
------------------------
LATEST DETECTED DEADLOCK
------------------------
2022-01-18 14:22:31 0x7f8c4c0b9700
*** (1) TRANSACTION:
TRANSACTION 4218932, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 88231, OS thread handle 140241, query id 9182733 10.0.1.31 order updating
UPDATE t_order_item SET status = 'RECEIVED' WHERE order_id = 9912345 AND sku_id = 100234

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 382 page no 412 n bits 152 index PRIMARY of table `order_db`.`t_order_item`
 trx id 4218932 lock_mode X locks rec but not gap

*** (2) TRANSACTION:
TRANSACTION 4218933, ACTIVE 3 sec starting index read
...
*** WE ROLL BACK TRANSACTION (2)

它只保留最近一次死锁,一秒钟内发生三次你也只能看到第三次。生产环境这么用基本抓不到东西。

开启 innodb_print_all_deadlocks

这个参数打开后,所有死锁都会写进 MySQL 的 error log:

mysql> SET GLOBAL innodb_print_all_deadlocks = ON;        -- 立即生效,重启失效
# /etc/my.cnf
[mysqld]
innodb_print_all_deadlocks = ON
log_error = /data/mysql/log/error.log

它是动态参数,不需要重启,但记得写进配置文件。打开之后我盯了一天,error log 里出现了 137 次死锁记录——比我们从业务日志里看到的 200 多次少,说明还有一部分是别的表上的死锁。

用 performance_schema 看实时锁等待

MySQL 8.0 的 performance_schema.data_locksdata_lock_waits 能看到当前正在发生的锁信息,比 5.7 的 information_schema.innodb_locks 详细得多:

mysql> SELECT
    ->   r.trx_id waiting_trx_id,
    ->   r.trx_mysql_thread_id waiting_thread,
    ->   r.trx_query waiting_query,
    ->   b.trx_id blocking_trx_id,
    ->   b.trx_mysql_thread_id blocking_thread,
    ->   b.trx_query blocking_query
    -> FROM performance_schema.data_lock_waits w
    -> JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_engine_transaction_id
    -> JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id\G
*************************** 1. row ***************************
   waiting_trx_id: 4218933
   waiting_thread: 88232
    waiting_query: UPDATE t_order_item SET status='RECEIVED' WHERE order_id=9912345 AND sku_id=100235
  blocking_trx_id: 4218932
  blocking_thread: 88231
   blocking_query: UPDATE t_order_item SET status='RECEIVED' WHERE order_id=9912345 AND sku_id=100234

注意:这两个表只在有锁等待时才非空,死锁被回滚后就看不到了,所以它是"现在进行时"的工具,不是"事后追溯"的工具。

第二步:采集和告警

用 mysqld_exporter 拿死锁计数

我们已经在用 mysqld_exporter(v0.14.0)了,它直接暴露了死锁总数:

$ curl -s http://10.0.1.10:9104/metrics | grep -i deadlock
# HELP mysql_global_status_innodb_deadlocks Generic metric from SHOW GLOBAL STATUS.
# TYPE mysql_global_status_innodb_deadlocks untyped
mysql_global_status_innodb_deadlocks 137

这是个累计计数器(从 MySQL 启动累加),告警要用增长率:

告警:死锁增长率(严重)
expr: increase(mysql_global_status_innodb_deadlocks[5m]) > 3
for: 5m
labels:
  severity: critical
annotations:
  summary: "MySQL 5 分钟内发生 {{ $value }} 次死锁"

告警:死锁增长率(警告)
expr: increase(mysql_global_status_innodb_deadlocks[1h]) > 10
for: 10m
labels:
  severity: warning

另外一个指标也很关键——锁等待时长。死锁是极端情况,锁等待排队才是日常,它比死锁早得多:

告警:InnoDB 行锁平均等待时间
expr: rate(mysql_global_status_innodb_row_lock_time_avg[5m]) > 100    # 单位 ms
for: 5m

告警:正在等待行锁的线程数
expr: mysql_global_status_innodb_row_lock_current_waits > 5
for: 2m

把死锁详情采集进 ES

光有计数不够,出事的时候要看具体 SQL。我写了个 20 行的脚本,每分钟读一次 error log 的增量,把新增的死锁段落打进 Elasticsearch 8.0:

#!/usr/bin/env python3
# deadlock-collector.py
import re, json, subprocess, requests

LOG = "/data/mysql/log/error.log"
POS_FILE = "/data/deadlock/.offset"

def read_offset():
    try:
        return int(open(POS_FILE).read().strip())
    except Exception:
        return 0

offset = read_offset()
data = subprocess.run(["tail", "-c", f"+{offset + 1}", LOG],
                      capture_output=True).stdout.decode("utf-8", "ignore")

# 死锁段落以时间戳行开始,到 "WE ROLL BACK" 结束
blocks = re.findall(
    r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z \d+ \[Note\] InnoDB:.+?"
     r"(?:WE ROLL BACK TRANSACTION \(\d\)|ROLL BACK TRANSACTION))",
    data, re.S)

for b in blocks:
    doc = {
        "host": "mysql-master-01",
        "ts": re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", b).group(0),
        "raw": b,
        "tables": list(set(re.findall(r"table `(\w+)`\.`(\w+)`", b))),
        "sqls": re.findall(r"^\s*(UPDATE|DELETE|INSERT|SELECT).*$", b, re.M)
    }
    requests.post("http://es-cluster:9200/mysql-deadlock-/_doc",
                  json=doc, headers={"Content-Type": "application/json"})

with open(POS_FILE, "w") as f:
    f.write(str(offset + len(data.encode("utf-8"))))

配成 cron 每分钟跑一次:

* * * * * /usr/bin/python3 /data/scripts/deadlock-collector.py >> /var/log/deadlock-collector.log 2>&1

有了这个之后,我在 Kibana 上建了个看板,按表名聚合。第一天就出了一个结论:87% 的死锁集中在 t_order_item,而且全部是 UPDATE ... WHERE order_id = ? AND sku_id = ? 这一条语句。

第三步:根因和规避

看采集到的一段死锁记录:

*** (1) TRANSACTION:
UPDATE t_order_item SET status='RECEIVED' WHERE order_id=9912345 AND sku_id=100234
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index idx_sku_id of table `order_db`.`t_order_item`
 lock_mode X waiting

*** (2) TRANSACTION:
UPDATE t_order_item SET status='RECEIVED' WHERE order_id=9912345 AND sku_id=100235
*** (2) HOLDS THE LOCK(S):
index idx_sku_id, lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
index PRIMARY, lock_mode X locks rec but not gap waiting

一个订单有 3 个 SKU,确认收货时循环更新这 3 行。两个线程处理同一个订单,线程 A 按 sku 顺序 100234 → 100235 → 100236,线程 B 因为查出来的顺序不同,按 100236 → 100235 → 100234 更新。于是经典的交叉加锁

代码是这样的:

@Transactional
public void confirm(Long orderId) {
    List<OrderItem> items = itemMapper.selectByOrderId(orderId);   // 顺序不保证
    for (OrderItem item : items) {
        itemMapper.updateStatus(item.getOrderId(), item.getSkuId(), "RECEIVED");
    }
    orderMapper.updateStatus(orderId, "FINISHED");
}

规避策略一:给更新排序

for (OrderItem item : items.stream()
        .sorted(Comparator.comparing(OrderItem::getSkuId))
        .collect(Collectors.toList())) {
    itemMapper.updateStatus(item.getOrderId(), item.getSkuId(), "RECEIVED");
}

保证所有事务按同一个顺序加锁,交叉就不可能发生了。改完之后这类死锁直接归零。

规避策略二:合并成一条 SQL

更彻底的做法是别在循环里更新,一条语句搞定:

UPDATE t_order_item SET status = 'RECEIVED'
WHERE order_id = #{orderId} AND status = 'SHIPPED'

一次加锁、一次释放,根本没有交叉的机会。而且从 3 次网络往返变成 1 次,确认收货接口的 P99 从 42 ms 降到 18 ms。

规避策略三:缩短事务

我们原来的 confirm 方法里,在更新数据库之后还调了两个外部接口(发短信、更新物流状态),整个事务持锁时间平均 210 ms。把它们挪到事务外,用事件发布:

@Transactional
public void confirm(Long orderId) {
    itemMapper.batchUpdateStatus(orderId, "RECEIVED");
    orderMapper.updateStatus(orderId, "FINISHED");
    // 不再在这里发短信、查物流
}

// 事务提交后触发
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onConfirmed(OrderConfirmedEvent event) {
    smsClient.send(event.getPhone(), "...");
    logisticsClient.sync(event.getOrderId());
}

事务持锁时间从 210 ms 降到 12 ms。

规避策略四:应用层重试

死锁是可重试的错误,MySQL 的错误信息里那句 try restarting transaction 就是这个意思。但重试要有限制,而且要避开其他不可重试的错误:

@Retryable(
    value = {MySQLTransactionRollbackException.class, DeadlockLoserDataAccessException.class},
    maxAttempts = 3,
    backoff = @Backoff(delay = 50, multiplier = 2, random = true))
@Transactional
public void confirm(Long orderId) { ... }

@Recover
public void recover(MySQLTransactionRollbackException e, Long orderId) {
    log.error("确认收货重试 3 次仍死锁,orderId={}", orderId, e);
    alertService.send("订单确认收货失败", orderId);
}

random = true 让退避时间带随机抖动(50 ms、100 ms、200 ms 上下浮动),避免两个线程同步重试再次撞上。这层重试挡掉了剩余死锁里的大多数,业务方感知到的失败基本消失了。

效果

指标改造前改造后
日均死锁次数1374
业务侧感知失败约 210 次/天0~1 次/天
确认收货 P9942 ms18 ms
事务平均持锁时间210 ms12 ms
死锁发现方式业务方反馈告警(5 分钟内)

小结

  • SHOW ENGINE INNODB STATUS 只保留最后一次死锁,生产上必须开 innodb_print_all_deadlocks = ON(动态参数,运行时即可开启)。
  • performance_schema.data_locks / data_lock_waits 只在正在等待时非空,用于实时排查;事后追溯要靠 error log。
  • 告警用 increase(mysql_global_status_innodb_deadlocks[5m]),它是累计计数器。同时监控 innodb_row_lock_time_avginnodb_row_lock_current_waits,锁等待比死锁早得多。
  • 业务侧四招:更新前按固定顺序排序(最有效)、循环更新改批量单条、事务里不要调外部接口、对死锁做带随机抖动的重试
  • 重试只针对 MySQLTransactionRollbackException / DeadlockLoserDataAccessException,别把唯一键冲突之类的逻辑错误也重试进去。

还有一句:死锁这事儿,监控建起来之前我根本不知道一天有 137 次,业务只报上来 20 多条。剩下的 110 多次都被各自业务方的重试吃掉了,只是白跑了几遍。量化的第一步永远是能看见。

参考