适用人群:后端同学、运维同学、需要排查“两个库同一条设备状态不一致”的场景
关键词:MySQL 跨库事务、binlog(ROW)、mysqlbinlog、时区、触发器审计


背景:为什么要做 A 与 B 状态强一致

在项目里,A 系统B 系统都有设备表,并且设备的“运行状态(0~9)”在业务上要求一致。
早期做法通常是:

  • 只更新 A 库,B 通过异步事件/定时补偿同步;或
  • 两边各写各的,偶发不一致后靠人工 SQL 修正。

当设备在线/离线、告警、离网运行等状态频繁跳变时,这些方案会出现典型问题:

  • 消息并发、乱序导致写入覆盖;
  • 异步同步失败或延迟,导致页面展示不一致;
  • 运维部署时系统卡顿,某些更新丢失或延迟执行。

因此我们选择了“方案A:同 MySQL 实例两库(两个 schema),A 为主,B 同步写入,强一致”:

  • 在一次事务中同时更新 A.eiot_device_infoB.device
  • 任何一边更新失败,整体回滚,避免“只更新一半”

现象:日志显示 B 更新成功,但最终 B 表里 state 变成 0

我们在 DevicePropertyHandler 里根据设备上报 runStatus 更新状态:

  • 设备状态更新日志(示例):
2026-03-13 10:57:43.077 DevicePropertyHandler | 设备:PCSEPCS105202512050001状态更新 [8 -> 7]
2026-03-13 10:57:43.080 DeviceInfoServiceImpl | [A]更新设备状态成功: deviceSn=PCSEPCS105202512050001, state=7
2026-03-13 10:57:43.082 DeviceInfoServiceImpl | [B]更新B设备状态成功: deviceSn=PCSEPCS105202512050001, state=7

但事后在数据库里看到:

  • A.eiot_device_info.state = 7
  • B.device.state = 0

直觉上会怀疑:

  • 事务没生效?
  • 并发乱序覆盖?
  • 读到了从库旧数据?

但这类问题一定要用“证据链”说话:日志 + 数据库写入记录(binlog)


先把链路串起来:到底是谁在更新状态

状态更新调用链(简化):

  1. DevicePropertyHandler.updateDeviceStatus(...)
  2. deviceApi.updateDeviceState(deviceSn, runStatus)
  3. DeviceInfoServiceImpl.updateDeviceState(deviceSn, state)@Transactional
  4. 事务内:
    • 更新 A.eiot_device_info
    • 更新 B.device(同一个 MySQL 实例,不走 MQ)

我们在 DeviceInfoServiceImpl.updateDeviceState 里打印了成对日志:

  • [A]更新设备状态成功 ...
  • [B]更新B设备状态成功 ...

这意味着:10:57:43 这次写入,A 和 B 在那一刻确实都写到了 state=7。

那么“B 最后变成 0”只有一种解释:

在 10:57:43 之后,又有另外一个写入B.device.state 从 7 改回了 0。

接下来我们要找的就是:这次“改回 0”的写入来自哪里。


用 binlog 取证:谁在什么时候把 state 改成 0

1)确认 binlog 是否开启、格式是什么

进入 MySQL:

SHOW BINARY LOGS;
SHOW VARIABLES LIKE 'binlog_format';

要点:

  • binlog 文件名的数字 越大越新(如 binlog.000157binlog.000156 新)
  • binlog_format=ROW 很常见:写入记录按“行变更”存储,适合精准还原字段变化

2)注意:mysqlbinlog 不是 SQL 命令,要在 Linux shell 执行

很多人第一次会在 mysql> 里敲 mysqlbinlog ...,会得到语法错误。
正确方式:退出 mysql 客户端,在服务器终端执行:

cd /var/lib/mysql
# 这个命令容易卡住,用ctrl+z退出,有时候ctrl+c退不出
mysqlbinlog binlog.000157 | less
# 如果只需要看最新一百行,可以这样:先生成 SQL 文件,再用 tail 或 less
mysqlbinlog --no-defaults binlog.000157 > binlog.sql
tail -n 100 binlog.sql | less

3)时区坑:应用日志是本地时间,binlog 时间可能是 PST/UTC

我们在 binlog 尾部看到了:

# original_commit_timestamp=... (2026-03-13 16:52:16.381990 PST)

这说明:binlog 输出使用了 PST
如果你拿应用日志的 2026-03-13 10:57 直接作为 --start-datetime,会查错时间段。

实践建议:

  1. mysqlbinlog ... | tail 看它标注的时区
  2. 把应用时间换算到 binlog 输出的时区后再查

4)用 grep 快速定位某设备相关写入(按 device_sn)

我们要找 PCSEPCS105202512050001 这台设备在 B 表的更新:

mysqlbinlog --no-defaults --base64-output=DECODE-ROWS -vv \
  --start-datetime="2026-03-12 18:50:00" \
  --stop-datetime="2026-03-13 16:10:00" \
  binlog.000156 | grep -n "PCSEPCS105202512050001" -C5

这一步能初步确认:

  • 有没有出现 UPDATE \B`.`device``
  • 大概在哪个时间点附近发生

5)拿到关键证据:state 从 7 被写成 0

进一步缩小时间窗(示例):

mysqlbinlog --no-defaults --base64-output=DECODE-ROWS -vv \
  --start-datetime="2026-03-13 15:28:20" \
  --stop-datetime="2026-03-13 15:28:50" \
  binlog.000156 \
| sed -n '/### UPDATE `B`.`device`/,/COMMIT/p' \
| sed -n '/PCSEPCS105202512050001/,$p'

输出里会出现类似(重点看 @8):

### UPDATE `B`.`device`
### WHERE
###   @2='PCSEPCS105202512050001'
###   @8=7
...
### SET
###   @2='PCSEPCS105202512050001'
###   @8=0
...
COMMIT

这就是铁证:

  • 15:28:33(PST)
  • B.device.state 7 → 0

6)把 @n 映射回列名(避免看错字段)

binlog 的 ROW 输出用 @1/@2/... 表示列序号,所以必须确认列顺序:

SHOW CREATE TABLE `B`.`device`\G

我们看到列顺序里:

  • @2 对应 device_sn
  • @8 对应 state

因此 @8=7 → @8=0 就是 state 被改回 0,不是别的字段(比如 dept_id)。


结论:不是“强一致没生效”,而是“B 侧又写了一次覆盖”

证据链合并:

  1. 10:57:43 应用日志显示 [A][B] 都更新成功到 state=7
  2. 15:28:33 binlog 显示 B.device.state 7 → 0
  3. 且这次写入 只更新了 B 表,没有同时更新 A 表(说明不是 A 事务链路写的)

因此根因不是事务问题,也不是“那次没更新到 B”,而是:

B(或某个独立进程/脚本/任务)在后续把 state 覆盖成 0。

典型来源包括:

  • B 内部“离线判定/状态纠正”定时任务
  • 后台保存设备信息接口,携带默认 state=0 覆盖
  • 运维脚本批量修正

如何继续追“到底是谁写的”:两种抓凶手方案

binlog(ROW)能告诉你“改了什么”,但通常不直接带 user/host
要找来源,推荐以下两种方式。

方案1:短时间开启 general_log(需要能复现/等待再次发生)

查看 general_log 状态:

SHOW VARIABLES LIKE 'general_log%';

如果 general_log=OFF,可以短时间打开(建议 1~2 分钟内关掉):

SET GLOBAL general_log = 'ON';
-- 等待/复现
SET GLOBAL general_log = 'OFF';

然后查看 general_log_file 中记录的 user@host 与 SQL。

注意:general_log 开销大,尽量短开。

方案2:触发器审计(不需要立刻复现,推荐)

如果你不知道怎么复现,最好用触发器在 DB 侧“长期盯梢”,只记录 state 变更。

1)建审计表
CREATE TABLE IF NOT EXISTS `B`.`device_state_audit` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `device_sn` VARCHAR(64) NOT NULL,
  `old_state` TINYINT NULL,
  `new_state` TINYINT NULL,
  `change_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `connection_id` BIGINT NOT NULL,
  `current_user_name` VARCHAR(128) NOT NULL,
  `session_user_name` VARCHAR(128) NOT NULL,
  `host_name` VARCHAR(128) NULL,
  PRIMARY KEY (`id`),
  KEY `idx_device_sn_time` (`device_sn`, `change_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2)建触发器(只要 state 变就记录)
DROP TRIGGER IF EXISTS `B`.`trg_device_state_audit`;
DELIMITER //
CREATE TRIGGER `B`.`trg_device_state_audit`
BEFORE UPDATE ON `B`.`device`
FOR EACH ROW
BEGIN
  IF (NOT (OLD.`state` <=> NEW.`state`)) THEN
    INSERT INTO `B`.`device_state_audit`(
      `device_sn`, `old_state`, `new_state`,
      `connection_id`, `current_user_name`, `session_user_name`, `host_name`
    ) VALUES (
      NEW.`device_sn`, OLD.`state`, NEW.`state`,
      CONNECTION_ID(), CURRENT_USER(), USER(), @@hostname
    );
  END IF;
END//
DELIMITER ;
3)查询审计结果
SELECT *
FROM `B`.`device_state_audit`
WHERE device_sn = 'PCSEPCS105202512050001'
ORDER BY id DESC
LIMIT 50;

你会得到:

  • 哪个账号(current_user_name/session_user_name
  • 哪个连接(connection_id
  • 什么时候把状态改成了什么

这就能把“写入来源”锁定到某个服务/脚本账号上。


最终怎么解决(建议落地步骤)

这类问题最终要“堵住覆盖写”的口子,而不是在 A 侧不断重写。

推荐落地步骤:

  1. 先用触发器审计(方案2)抓到 “state=0 的写入来源账号/时间”
  2. 在 B 代码/脚本里定位对应逻辑:
    • 是否有离线判定任务?
    • 是否有保存设备信息接口把 state 默认写 0?
  3. 按业务规则修复:
    • 如果 B 的离线判定只想改“在线/离线”,不要覆盖 0~9 运行态:
      • 要么改写另一个字段(例如 online
      • 要么只在特定条件下更新(如 state 为某些值才允许覆盖)
  4. 增加防御:
    • 对写入 0 的逻辑加日志(deviceSn、old/new、调用栈)
    • 或加 DB 侧约束/触发器拦截(谨慎使用)

附:常用命令速查

查看 binlog 文件列表(数字越大越新)

SHOW BINARY LOGS;

解析 binlog(ROW)为可读输出

mysqlbinlog --no-defaults --base64-output=DECODE-ROWS -vv binlog.000156 | less

快速搜某设备 SN 的写入

mysqlbinlog --no-defaults --base64-output=DECODE-ROWS -vv binlog.000156 \
  | grep -n "PCSEPCS105202512050001" -C5

确认列顺序(把 @n 映射到列名)

SHOW CREATE TABLE `B`.`device`\G

总结

这次问题的关键不是“猜”,而是“取证”:

  • 日志证明:A→B 强一致更新在 10:57:43 是成功的
  • binlog 证明:15:28:33 有另外一次写入把 B state 从 7 改成 0
  • 下一步:用触发器审计/短期开 general_log 找出写入来源,修复 B 侧覆盖写逻辑

只要把“覆盖写 state=0 的源头”处理掉,A 与 B 的状态就能稳定一致。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐