做了几年后端开发和数据库运维让我对“同步”这个词又爱又怕。爱的是同步机制是并发程序协作的基础没有它多线程、多实例、多副本的世界根本转不动怕的是只要同步设计有一点疏漏紧跟着就会遇到线程死锁、数据库死锁、同步任务互相卡死的现场排查起来又费时间又费头发。这篇博文我想把“同步与死锁”这件事从头到尾梳理一遍既讲清不同场景下同步机制的原理也会给出能直接落地的实操步骤比如把远程库的某张表同步到本地以及遇到死锁之后怎么定位、怎么解决。内容适合后端程序员、DBA、运维工程师也适合正在学并发编程的学生朋友。1. 同步不是一个词是一类问题的总称很多人第一次接触“同步”是在学并发编程的时候后面再遇到“数据库同步软件”“主从复制”“同步整流”“时钟同步”这些词就有点懵了。实际上“同步”在不同上下文里的含义差别很大但底层逻辑都是“让多个参与方在时间、状态或数据上对齐”。把这一点想清楚后面所有具体方案都会变得很好理解。1.1 先分清你遇到的同步是哪种同步第一种是并发同步解决的是多个线程或进程如何安全地共享资源。典型手段包括锁、条件变量、信号量、原子操作。比如两个线程同时往一个账本里写数字如果没有同步机制就可能出现覆盖写、读到半个结果的尴尬情况。这里的“同步”其实是“互斥协调”核心目标是避免竞态条件。第二种是数据同步解决的是多个存储位置之间的数据一致性。我经常用到的MySQL主从复制、PostgreSQL逻辑复制、DataX工具都属于这一类。它们把一份数据从一个库复制到另一个库或者从一个表同步到另一个表目的是做读写分离、容灾备份、数据汇聚。第三种是硬件级同步比如多传感器硬同步触发、EtherCAT DC时钟同步、同步Buck电路里的相位对齐。这类同步关注的是时间轴对齐常见于机器人、自动化控制和电源电路领域。虽然物理世界的问题和软件世界不太一样但“时序协调”这个本质是共通的。1.2 同步与异步为什么总是一起出现聊同步就绕不开异步这两个概念经常成对出现。同步调用是你发出一个请求后必须等结果回来才能继续往下走异步调用是你先发出请求不等待结果去做别的事等结果好了通过回调或事件来通知你。我用生活里的餐厅吃饭来打比方。同步点餐就是站在柜台前看着厨师做菜端上来你再端走异步点餐就是扫了码下单手机先收着你可以先聊微信取餐提醒一到再去窗口拿。前者逻辑简单但效率低遇到慢厨师整个流程都堵住后者响应快但复杂度高要处理通知、失败重试、结果乱序。这个话题放到JavaScript里就是经典的“Ajax是异步还是同步”问题放到后端就是SQLAlchemy里同步引擎和异步引擎的选择问题。我个人的排序是能用同步表达清楚逻辑就不强行切异步只有性能瓶颈确实出在等待IO上才考虑协程或异步框架。盲目异步化往往会让代码的可读性和排查难度同时上升。1.3 同步协作里为什么会有死锁死锁不是某种编程语言特有的bug它本质上是多个同步参与者因为互相等待资源而卡死。线程死锁是线程互相等锁数据库死锁是事务互相等行锁或表锁分布式系统里还会出现服务互相等远程调用。无论场景怎么变背后都是同一个循环等待模型。我见过不少同学在Java代码里遇到线程死锁就怀疑是编译器或JDK的问题其实大部分死锁都是业务代码把资源获取顺序写乱导致的。数据库死锁也一样两个事务更新了一部分数据后又同时去更新对方已经锁住的行MySQL的InnoDB会检测到并回滚其中一个事务但线程死锁往往没人给你自动解围只能卡在那里。所以搞懂死锁的成因是排查所有同步问题的第一步。2. 并发编程里的同步机制与死锁现场并发同步是“同步与死锁”这个主题里最经典、也最容易复现的领域。这部分的原理搞明白了后面看数据库死锁和分布式死锁都会觉得眼熟。2.1 常用同步原语速览先看最常用的几类同步原语。锁是最直接的互斥工具同一个锁只能被一个线程持有其他线程必须等待。条件变量允许线程在某个条件不满足时休眠并在条件满足时被唤醒典型使用场景是生产者-消费者队列。信号量可以控制同时访问某个资源的线程数量比如限流一个小程序最多允许5个请求并发访问共享连接池。原子变量则更轻量适合计数器这类简单场景不用加锁也能保证更新不被打断。我拿Python写个简单例子锁的使用是这么回事import threading lock threading.Lock() shared_count 0 def increment(): global shared_count for _ in range(100000): with lock: shared_count 1 threads [threading.Thread(targetincrement) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(shared_count)没有锁的时候shared_count 1不是原子操作多线程反复执行可能出现结果小于100000的情况。加上锁之后每次更新都有了互斥保护最终结果稳定正确。实际工程里锁的粒度需要仔细设计锁太大性能差锁太小临界区容易出问题。2.2 死锁的四个必要条件死锁要发生通常需要同时满足四个条件。第一个是互斥资源同一时刻只能被一个线程占用。第二个是持有并等待线程已经拿到一个资源还继续去等待另一个资源。第三个是不可剥夺资源只能由持有者主动释放不能被外部抢走。第四个是循环等待多个线程形成一条等待环比如A等B、B等A。下面这段Java代码就是一个非常典型的线程死锁class DeadlockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (lockA) { System.out.println(T1 locked A); try { Thread.sleep(100); } catch (Exception e) {} synchronized (lockB) { System.out.println(T1 locked B); } } }); Thread t2 new Thread(() - { synchronized (lockB) { System.out.println(T2 locked B); try { Thread.sleep(100); } catch (Exception e) {} synchronized (lockA) { System.out.println(T2 locked A); } } }); t1.start(); t2.start(); } }这段代码跑起来不一定每次都死锁但只要t1先锁A然后准备锁Bt2同时锁B然后准备锁A两个线程就会永远等下去。这也是死锁最坑的地方它和线程调度时序强相关有时能稳定复现有时跑一万次都没事到了线上压力大的时候突然出现。2.3 写代码时如何避免线程死锁避开线程死锁我这些年积累了几条特别管用的经验。第一条是保证锁顺序一致。如果多个线程必须获取多把锁就统一按照哈希值排序或业务主键排序来获取杜绝一边先锁A再锁B、另一边先锁B再锁A的写法。这条规矩能解决绝大多数嵌套锁死锁。第二条是尽量减少持有的锁数量。能用一把锁讲清楚的事不要拆成两把锁能使用原子操作的地方不要用锁能用不可变对象的地方根本不需要锁。单锁模型出问题的概率远低于多锁模型。第三条是用带超时的锁获取机制。比如Java的tryLock可以指定等待时间拿不到锁就放弃并重试而不是死等。数据库层的lock_wait_timeout也有类似作用让长时间卡住的事务自动退出避免把整个链路拖死。第四条是保持临界区足够短。锁绝不是用来包着整个业务逻辑的应该只包住真正需要保护的那几行共享变量操作。锁内的睡眠、远程调用、文件IO都是大忌容易让锁的等待时间爆炸式增长。3. 数据同步实战把远程库的表同步到本地还要躲开死锁如果说线程死锁是并发编程的经典问题那么数据同步里的死锁和同步失败就是后端日常运维的家常便饭。热词里有一条非常具体的需求“把远程库的这张表同步到本地”我以这个场景为例走一遍完整的实操流程。3.1 MySQL 主从复制最经典的同步方案MySQL主从复制是数据库层面最成熟的同步机制之一核心原理是主库把变更记录写入二进制日志binlog从库的IO线程负责拉取binlog并写到本地的relay log最后由SQL线程回放relay log完成数据变更。这个流程实现了一台主库向多台从库复制数据性能开销可控延迟通常在毫秒到秒级。具体操作步骤如下。先在主库上做两件准备一是开启二进制日志二是设置唯一server-id。编辑MySQL配置文件加入下面内容[mysqld] log-binmysql-bin server-id1然后重启主库MySQL创建一个专门用于复制的账号并授权CREATE USER repl% IDENTIFIED BY YourPassword; GRANT REPLICATION SLAVE ON *.* TO repl%; FLUSH PRIVILEGES;接着查看主库当前binlog坐标这一步很关键SHOW MASTER STATUS;你会看到类似File: mysql-bin.000005, Position: 154的信息这就是从库要开始复制的起点。如果要复制整库可以把主库现有数据用mysqldump导出再导入到从库。但在线环境我更推荐使用xtrabackup这类物理备份工具它们不需要长时间锁表。在从库上配置复制源CHANGE MASTER TO MASTER_HOST192.168.1.10, MASTER_USERrepl, MASTER_PASSWORDYourPassword, MASTER_LOG_FILEmysql-bin.000005, MASTER_LOG_POS154; START SLAVE;启动后用下面这条命令检查复制状态重点关注两个字段SHOW SLAVE STATUS\G;正常情况应该是Slave_IO_Running: Yes和Slave_SQL_Running: Yes。如果只同步某一张表可以在从库配置里加replicate-do-tabledbname.tablename这样同步范围就只限定在这一张表适合“把远程库的这张表同步到本地”的精确场景。这里有几个容易踩的坑。第一主从两端配置的server-id一定不能相同否则从库会报错。第二如果主库已有存量数据必须保证从库起点一致否则很容易出现主键冲突或找不到更新行的错误。第三生产环境强烈推荐打开GTID模式它让日志位置跟踪变得自动且可靠故障切换时体验好很多。3.2 用 DataX 做多实例增量同步的具体步骤有些场景不满足于MySQL间的主从复制比如要从PostgreSQL同步到MySQL或者要把多个数据库实例的数据汇到本地数仓。这时候我会选择DataX。DataX是异构数据源离线同步框架通过插件机制支持MySQL、PostgreSQL、SQLServer、HDFS、ClickHouse等几十种数据源只需写一个Job配置就能完成搬运。以“从远程PostgreSQL实例同步一张表到本地MySQL”为例大致逻辑是这样。先准备一台装了DataX的机器然后写一个job.json配置{ job: { content: [ { reader: { name: postgresqlreader, parameter: { username: readonly_user, password: YourPassword, column: [id, name, update_time], splitPk: id, connection: [ { jdbcUrl: [jdbc:postgresql://remote-host:5432/dbname], table: [public.tablename] } ], where: update_time 2024-01-01 00:00:00 } }, writer: { name: mysqlwriter, parameter: { username: sync_user, password: YourPassword, writeMode: insert, column: [id, name, update_time], connection: [ { jdbcUrl: jdbc:mysql://local-host:3306/dbname, table: [tablename] } ] } } } ], setting: { speed: { channel: 4 } } } }执行同步任务用一条命令python datax.py job.json日常增量同步我建议在任务里维护一个每次执行的最大主键或最大更新时间下一次任务把where条件改成大于这个游标。这样每轮只同步新增和变化的数据避免全表搬运。要注意的是DataX的离线同步适合分钟级及以上间隔的作业如果要毫秒级实时同步应该考虑Canal或Debezium这类CDC工具。3.3 数据库死锁同步任务里最常见的坑数据同步任务更容易触发数据库死锁因为同步任务通常批量更新多行事务范围大锁持有的时间也长。举一个我调过很多次的场景同步线程A先更新A表的一行再去更新B表的一行同步线程B刚好反向操作。两个线程各自都锁住了一个表又都在等对方的表锁InnoDB检测到死锁后会选择回滚代价较小的事务但你的同步任务很可能就因为这个回滚而中断。排查数据库死锁我有一套固定动作。先是查看最近发生的死锁信息在MySQL里执行SHOW ENGINE INNODB STATUS\G;在输出中找到LATEST DETECTED DEADLOCK片段里面会列出两个事务分别执行了什么语句、持有和等待哪些锁、哪个事务被回滚。这是第一手证据。如果想看当前正在运行的事务状态可以查SELECT * FROM information_schema.INNODB_TRX;通过trx_state、trx_started、trx_query字段能判断是否有长期未提交的事务。结合performance_schema.data_locks还能看清每个锁的行锁和表锁归属定位到具体语句并不难。解决同步任务里的数据库死锁我推荐的方案是让所有同步事务以同一顺序更新表每批处理行数控制在几百行不要把上百万行放在一个事务里给关键表建立合适索引缩小行锁扫描范围在应用层捕获死锁重试异常连续重试2到3次。这些手段组合使用能把死锁发生概率降到很低。4. 同步与死锁问题的通用排查工具箱前两章已经涉及线程死锁和数据库死锁这里我把排查方法整理成一个通用工具箱方便大家遇到问题时直接参考。4.1 JVM线程死锁排查一条命令见分晓如果Java进程卡死先找到进程ID然后用jstack打印线程快照jstack 12345 thread_dump.txt打开文件后搜索deadlock关键字。JDK自带的jstack会在检测到死锁时明确打印“Found one Java-level deadlock”并列出等待链。如果问题没有严重到触发死锁检测也可以搜索BLOCKED状态线程看看它们各自在等哪个锁对象。线上环境经常遇到业务进程不能随便重启所以我习惯先抓两份间隔几秒的线程快照对比看哪些线程始终停在同一个锁等待位置这比单独看一份快照更可靠。在用Linux服务器时还可以用top -Hp 12345查看线程级CPU占用配合jstack定位热点线程。4.2 PostgreSQL和MySQL锁等待实时查询PostgreSQL的死锁排查和MySQL略有不同PG也会自动检测并回滚死锁事务但查找等待关系需要查视图。我常用的SQL是查pg_stat_activity里的等待事件以及pg_locks里的锁信息SELECT a.pid, a.state, a.wait_event_type, a.wait_event, a.query, l.locktype, l.mode FROM pg_stat_activity a LEFT JOIN pg_locks l ON a.pid l.pid WHERE a.state ! idle;如果发现某个pid一直处于client wait或其他锁等待事件顺着所有会话的queries基本能拼出互相等待的关系。MySQL侧则记住两把工具SHOW ENGINE INNODB STATUS看历史死锁performance_schema.data_locks看当前锁持有和等待情况。有了这两张表任何锁等待都能还原成清晰的依赖链。4.3 设计同步方案时的几条铁律回顾我处理过的奇奇怪怪同步问题真正值得长期遵守的经验其实不多但每一条都很重要。数据同步必须设计成可重入和幂等。同一条数据被传输两次不能产生两份结果。MySQL主从复制靠日志天然实现幂等重放但DataX这类搬运工具如果使用insert模式重复执行就会主键冲突。因此我通常把目标表设计成唯一键更新策略比如用replace或upsert即便任务被重跑也不会污染数据。同步任务要有明确的超时和重试策略。线程等待锁可以设置超时数据库事务可以设置锁等待上限同步作业失败后要自动重试并告警。最怕的就是任务失败后一直卡在那里既不退出也不报错下一轮的同步数据又被覆盖。监控同步延迟是必须的。主从延迟、CDC延迟、同步失败率这些指标要接到监控系统。同步链路往往不是故障第一现场但它是灾备和数据一致性的生命线等到业务报表对不上账的时候再去查同步就已经迟了。4.4 代码评审时如何快速检查死锁风险日常code review里我会重点扫几类代码。第一类是看到两个以上锁嵌套的地方立刻问一句“这两个锁的获取顺序其他路径是不是一致”答案只要有一点犹豫就有死锁风险。第二类是事务方法里如果包含远程调用比如在开启了事务的Service里调用另一个微服务接口大概率需要一个超时机制否则你的数据库连接会一直握在手里锁也会一直持有。第三类是批量循环里逐条更新不同的表这种代码很容易在不同线程间形成反向顺序适合抽出来统一排序再更新。把这些检查习惯固化下来比事后排查死锁更省力。我自己的体会是死锁不是玄学它只是在资源获取顺序上缺乏一致性而已。只要画出每个参与者的资源获取顺序图哪里有环哪里就会死锁把环打开问题自然消失。最后再分享一个我工作里经常用的思路任何同步任务上线前我都会做一次“故障演练”故意把网络延迟调大把并发线程数量翻倍看同步链路会不会锁等待、死锁或堆积。踩过几次坑之后你会慢慢养成对锁顺序、事务边界和重试逻辑的本能敏感这可能是处理“同步与死锁”最有价值的经验积累。