[用事实说明两个凡是]一个由mysql事务隔离级别形成的问题分析

背景

最近要作一个批跑服务, 基本逻辑就是定时扫描数据库的记录, 有知足条件的就进行处理(一条记录表明一个任务,如下任务与记录含义相同). 要求支持多机部署批跑服务.php

批跑支持多机部署实现方案

要实现多机部署, 只要保证每一个批跑服务实例每次只获取一条记录, 处理完再获取下一条便可. 其中最种要的是避免不一样的实例获取到同一条记录,即所谓抢任务.mysql

先看表结构设计:sql

create database if not exists ae;
create table ae.task (
id int primary key,
status int);
-- status为0说明任务可处理,其它不可处理

以上是简化的表结构,但足以说明本文试图说明的问题.数据库

要避免抢任务, oracle的作法, 直接session

update ae.task set status=1 where status=0 and rownum = 1 returning id

便可.并发

mysql的要啰嗦一点:oracle

select id from ae.task where status=0; -- 获得ID
update ae.task where id = ${id} and status=0;

这两个sql,第一个sql用于获取符合条件的任务, 第二个sql用户将任务锁定. 在并发的场景下, 有可能不一样的批跑实例的第一个SQL会返回相同的记录, 但第二个sql只有一个会更新成功, 经过判断affected rows便可知道哪一个锁定成功. 锁定成功的继续处理本任务, 锁定失败的继续处理其它任务.post

问题现象

管理后台提交了一个任务后, 两个批跑实例刚好同时启动, 进入抢任务环节. 结果发现异常, 其中一个实例成功抢到任务, 但另外一个实例则挂死了:this

抢到任务的实例:设计

2015-11-23 19:42:01|INFO|exec_task.php|40||get one task: 11
...
2015-11-23 19:42:01|INFO|exec_task.php|107||line_count: 9
2015-11-23 19:42:01|INFO|exec_task.php|147||fork child success: 8346
2015-11-23 19:42:01|INFO|exec_task.php|264||[0] pid: 8346, start: 0, stop: 0

2015-11-23 19:42:01|INFO|exec_task.php|147||fork child success: 8347
2015-11-23 19:42:01|INFO|exec_task.php|264||[1] pid: 8347, start: 1, stop: 1

2015-11-23 19:42:01|INFO|exec_task.php|147||fork child success: 8348
2015-11-23 19:42:01|INFO|exec_task.php|264||[2] pid: 8348, start: 2, stop: 2

2015-11-23 19:42:01|INFO|exec_task.php|147||fork child success: 8349
...

没有抢到任务的实例:

2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task

能够看到没有抢到任务的实例进入了死循环.

缘由分析

按照咱们以前的设计, 若是第二条SQL锁定任务的时候失败了, 获取下一个任务. 应当不会死循环. 死循环的缘由是由于没有抢到任务的实例, 在执行第一个SQL的时候, 一直返回了相同的记录(id=11,实际上当时也只有一条记录)

请注意, 抢到任务的实例抢到任务后, 会把状态更新并提交, 按说抢不到任务的实例会看到此状态更新,并致使第一条sql查不到数据,而后 正常退出.

而事实上抢不到任务的实例看不到此变化, 说明事务隔离级别(Transaction Isolation Level)不是"READ COMMITED", 而是其它. 经确认, 级别是"REPEATABLE-READ"

mysql> select @@TX_ISOLATION;
+-----------------+
| @@TX_ISOLATION  |
+-----------------+
| REPEATABLE-READ |

"REPEATABLE-READ" 看到的数据是事务启动时的样子,因此看不到抢到任务的实例对任务状态的修改. 进而致使死循环.

请注意执行第一个SQL查询知足条件的任务是在一个事务内进行的. 此事务其实是业务的须要, 除了获取到任务,还须要获取其它资源,若是获取不到其它 资源, 则rollback任务,以便下次处理.

ORACLE相应的事务隔离级别是"Serializable Isolation Level", 如上描述的这个场景, 在ORACLE下的反应是抢不到任务的实例在试图更新任务状态的 时候,会返回一个"ORA-08177: Cannot serialize access for this transaction"错误, 程序也能够正常退出. 详见<<Oracle Database Concepts 11g Release 2 (11.2) E40540-04>> 第9章"Overview of Oracle Database Transaction Isolation Levels"

mysql在"REPEATABLE-READ"的事务隔离级别上的表现是不能让人满意的. 查询到的数据是事务启动时的样子,但更新的时候看到的数据又是其它事务提交 后的结果,而且update也没有错误提示.

而"SERIALIZABLE"更糟糕, 若是同时开了两个session, 干脆直接锁表了, 谁了更新不了. 这就势必形成另外一个问题, 既然你们都更新不了,那就rollback事务, 重试呗. 可是重试也是颇有可能你们再同时开了事务,又锁死了, 一直死循环. 为了解决这种状况,可能的作法是, 各自等待一个随机时间再重试,让随机打破这个僵局. 不知道是否有其它办法,欢迎指教.

解决方法

  1. 修改session的事务隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
  1. 不断查询知足条件的任务不要放到一个事务里. 发现"affected rows"为0,更新不到数据时, 事务rollback,从新启动事务. 即在循环里不断开启事务而不是在事务里不断循环.

  2. 还有一个办法是开事务而后select for update, 可是这种方法会致使锁表, 必须等待其它事务提交后才能返回. 当初我进行设计的时候,是计划使用select for update的方式的, 可是最终没有使用, 如今回想, 多是没有开事务, 结果两个实例都查询到了相同的记录, 因此被我否认了. 可是看我另外一个文章 <<mysql抢单>>又彷佛多是因为锁表而弃用了, 缘由已经不可考了.

但从本个需求来讲, 彷佛使用select for update来让把表锁住会更简单.

另外一个问题

你觉得抢到任务的实例就能够高枕无忧了吗, 错了! 等他高高兴兴处理完任务, 要把任务状态置为成功时, 发现这个任务竟然被没有抢到任务的实例给锁了, 自已只能获得一个锁超时的错误

2015-11-23 19:42:52|ERR|function.inc.php|113||SQL fail: Lock wait timeout exceeded; try restarting transaction

请期待下一个问题分析.

补充说明

今天回来确认了一下, 实际上ORACEL的update task set status = 1 where status = 0 and rownum = 1 returning taskid 这个SQL也会把表锁住.

因此能够用@flygogo 在30楼提出的方法模拟oracle 的returning

SET @update_id := 0; 
update ae.task set status=1, id = (SELECT @update_id := id) where status=0 limit 1; SELECT @update_id;

oracle

而postgresql的update彷佛没有limit 1之类的限定只更新一条的写法?

同时ORACLE和postgresql的select for update 也都会锁表.

因此就本文所讨论的范围来讲, 彷佛不能说是两个凡是. 叉!

再补充说明

差点被绕晕了. 其实本文所指出的mysql在"REPEATABLE-READ"事务隔离级别下的表现是奇怪的,不直观的,这点值得注意. 明明select出来的数据是可更新, 而更新时候又没有成功, 会让人很是疑惑. 而为oracel在"Serializable"级别下发现数据已经被更新了以后,抛出"ORA-08177"的作法才更直观更合适.

本文另外一个意义是分享了一种不锁表实现队列的方法

相关文章
相关标签/搜索