多应用服务器下Spring Poller重复读取MySQL记录问题求助
这问题我之前帮好几个团队排查过,多实例部署的定时任务重复执行确实是个常见的坑,尤其是用Spring Poller配合MySQL的时候。你说加了更新语句还是重复读,核心原因大概率是并发场景下的事务隔离性和原子性问题——两台服务器几乎同时执行查询,此时记录还没被更新,所以都读到了同一条数据。下面给你几个亲测有效的解决方案,按推荐优先级排序:
一、用SELECT ... FOR UPDATE加悲观锁(最直接的数据库层面解决)
这是最常用的方案,利用MySQL的行级排他锁,保证同一时间只有一个实例能拿到目标记录:
把你的查询语句改成带锁的形式:
SELECT * FROM your_table WHERE status = '待处理' LIMIT 10 FOR UPDATE;这里的
status = '待处理'是你用来标记未处理记录的条件,LIMIT 10是控制每次捞取的条数,避免锁太多行影响性能。把查询+更新状态放在同一个事务里:
拿到记录后,立刻更新它的状态(比如改成“处理中”),然后提交事务。这样其他实例在查询时,因为行被锁,会等待当前事务提交,此时记录状态已经改变,就不会读到重复数据了。注意:事务要尽可能短!别拿着锁去调用第三方接口(这通常是耗时操作),可以先把记录捞出来、更新状态,再异步处理第三方调用,减少锁的持有时间,避免影响数据库性能。
二、利用UPDATE的原子性做“乐观锁式”锁定(无需显式加锁)
如果不想用悲观锁,也可以借助MySQL UPDATE语句的原子性来实现分布式锁定,适合对性能要求较高的场景:
给你的表加两个字段:
lock_owner:varchar类型,用来存当前锁定该记录的实例ID(比如服务器的hostname或者应用实例的唯一标识)locked_at:datetime类型,记录锁定时间
先执行UPDATE语句锁定记录,再查询被当前实例锁定的记录:
-- 先锁定:只有当前实例能成功更新这些记录,其他实例更新行数为0 UPDATE your_table SET lock_owner = '你的实例ID', locked_at = NOW() WHERE status = '待处理' AND lock_owner IS NULL AND (locked_at IS NULL OR locked_at < DATE_SUB(NOW(), INTERVAL 30 SECOND)) LIMIT 10; -- 再查询当前实例锁定的记录 SELECT * FROM your_table WHERE lock_owner = '你的实例ID';这个方案的核心是UPDATE是原子操作,MySQL会保证同一时间只有一个事务能修改这些行,完全避免了并发读取的问题。加
locked_at的超时判断是为了防止某个实例挂掉,导致记录一直被锁定。
三、分布式锁(适合任务量不大的场景)
如果你的团队已经有Redis或ZooKeeper这类分布式组件,也可以在Poller执行前先获取分布式锁:
- 比如用Redis的
SETNX命令,只有拿到锁的实例才能执行轮询任务。 - 注意要给锁设置过期时间(比如10秒,比你的Poller间隔5秒长一点),避免实例挂掉导致死锁。
不过这个方案的缺点是:只有一个实例会执行轮询任务,如果你的Poller处理的任务量很大,可能会造成单点压力。如果需要所有实例都参与处理任务,那前两个方案更合适。
四、额外注意事项
- 检查你的事务隔离级别:MySQL默认的
REPEATABLE READ可能导致其他事务看不到未提交的更新,所以一定要确保查询和更新在同一个事务里,或者调整隔离级别为READ COMMITTED(不过一般不建议随便改隔离级别,前两个方案更稳妥)。 - 避免长事务:如果事务里包含调用第三方接口的逻辑,一定要把这部分抽出来异步处理,不然锁会被长时间持有,导致其他实例阻塞。
内容的提问来源于stack exchange,提问作者kana

