基于Oracle数据库锁机制,解决集群中的并发访问问题

1、需求

应用场景是这样的:

使用Oracle数据保存待办任务,使用状态字段区分任务是否已经被执行。多个Worker线程同时执行任务,执行成功或失败后,修改状态字段的值。

假设数据库表结构如下所示。

create table Task(
    id      varchar2(32),
    name    varchar2(32),
    flag    varchar2(1),
    worker  varchar2(32)
);

flag 可取的值包括:0-待办,1-已办,-1-失败待重试。

需要避免的问题:
多个Worker同时工作时,避免出现一个任务被执行多次的情况;避免任务的状态被改错。

2、分析

2.1、依赖Java语言的机制

Java语言的锁机制可以解决并发问题,但只能在单机情况下有效。

在Tomcat(或其他应用服务器)集群环境下,Java代码中的锁机制是解决不了这个问题的。
Java语言层面的各种同步、锁机制是在JVM内部的,处理不了跨JVM的情况。

作为锁的信号量,必须存储在独立于JVM的地方。可以是数据库,可以是Redis。

2.2、Quartz提供的支持

在生产环境中,为了避免单点故障,Quartz需要集群提供 HA( High Availability,高可用)支持。Quartz集群依赖将任务信息持久化到数据库中。

有两个可选的思路:

1、可以设置单个节点的worker数量为1。首先保证了在单个节点内不会有并发问题。是否能保证集群中同一个Job只有一个实例在跑,需要考察下 Quartz 提供的文档。

2、在任务类上使用 @DisallowConcurrentExecution 或者 StatefullJob。或许可以达到效果,需要实验。

采用Quartz管理并发问题,采取的是回避策略,不能充分利用计算资源。

Quartz的集群环境依赖JDBC存储,一方面需要通过数据库在节点间共享信息,另一方面,基于数据库的行集锁解决了并发问题。

Quartz 本身已经太庞大,不仔细阅读文档,甚至阅读源代码,也无从猜测其行为,作为企业级的 Timer ,它很好用。解决并发问题,还是找一个更清爽明了的方法吧。

2.3、通过数据库锁实现

在考虑解决问题的方案前,先回顾一下数据库的事务隔离级别和Oracle数据库的锁机制。

2.3.1、事务隔离级别

事务隔离级别是针对当前会话来说的。

SQL 92标准定义了4种事务隔离级别。

1、Read Uncommited : 可以读到其他会话未提交到。
这是4个级别中最低的。其他会话直接在数据上修改,当前会话会读到其他会话实时的修改状态。
当 会话B 修改了数据,被当前会话读到,会话B 又回滚了,则当前会话读到了“错误”的数据。 这称为“脏读”。

2、Read Commited :当前会话可以读到其他会话已经提交到数据。
这种隔离级别避免了“脏读”。
如果出现这种状况:

1) 当前会话读取数据;
2) 会话B 修改并提交了当前会话数据;
3) 当前会话再次提交,读到了会话B修改后的数据。

对于当前会话来说,两次读取数据,读到的不一样,这称为“不可重复读”。

这是Oracle默认的事务隔离级别。

3、Repeatable Read :当前会话看不到其他会话已经提交的数据修改,但可以看到其他会话新插入的数据。

不可重复读会出现的问题是:相同的查询条件,在同一个会话中反复执行,查询得到记录条数会不相同。这称为“幻读”。

4、Serializable :其他会话对数据的修改都不可见。

需要注意的是,不是其他会话不能修改数据,而是修改对当前会话不可见。

Oracle支持3种事务隔离级别。

Oracle支持Read Commited、Repeatable Read ,另外,支持 Read Only。
Read Only 是最彻底的掩耳盗铃。

事务隔离级别可以帮我们理解问题,但不是解决问题的方法。

解决问题,靠数据库的锁机制。

2.3.2、Oracle数据库的锁机制

我们需要的是DML锁,DML锁的目的在于保证并发情况下的数据完整性。在 Oracle 中,DML锁包括表级锁和行级锁。

select … for update 可以获得行级锁。
update执行也自动获得行级锁。

这样,我们可以有两个方法达到并发控制的目的:

方法一:

通过select … for update 获得行级锁,锁定若干任务,每条数据是一个任务。

然后执行任务,执行任务完成后,更新状态,提交事务,释放锁。

Quartz 本身是使用这种机制,解决集群中的并发问题的。

相关代码文件包括:

接口定义:
org.quartz.impl.jdbcjobstore.Semaphore。
模板方法:
org.quartz.impl.jdbcjobstore.DBSemaphore。
实现类:
org.quartz.impl.jdbcjobstore.StdRowLockSemaphore。
应用:
org.quartz.impl.jdbcjobstore.JobStoreSupport。

关键方法包括:obainLock,releseLock和executeInLock。

方法二:

按如下步骤执行:

1、执行如下SQL语句,抢占任务。

update Task t set t.worker = ‘worker-1‘ 
where t.worker is null 

可以对如上SQL进行改进,只抢占指定数量的任务,多余的任务留给其他 worker 做。

2、逐个执行已经抢占的任务。

可以通过如下SQL查询出已经抢占成功的任务信息。

select * from Task t 
where t.worker = ‘worker-1‘ and t.flag < 1

3、执行完成后,更改任务状态。

update Task t set t.flag = 1
where id = ‘some id

如果任务执行失败,放回去。

update Task t set t.flag = -1 , t.worker = null
where id = ‘some id‘

这种方法的要点在第一步。第一步是会出现并发问题的地方。

Oracle 的update语句会自动获得行级锁。我们可以做如下实验验证:

1)打开两个PL/SQL 窗口,模拟两个会话,每个窗口都将执行update语句,更新相同的行。比如:第一个窗口执行 update Task set flag=1 where flag=0 and id=‘1’,第二个窗口执行 update Task set flag=2 where flag=0 and id=‘1’。
2)先执行第一个窗口的update语句,不提交。
3)再执行第二窗口的 update 语句,发现“在等待”。
4)提交或回滚第一个窗口的事务后,发现第二窗口停止等待,执行了语句,等待提交。两条语句是串行执行的。

Oracle 本身对 update 的锁机制已经足以支持我们的工作。

方法三:

增加 version 或 timestamp 字段,使用乐观锁。

有了方法二,这种方法偏麻烦。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。