基于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 字段,使用乐观锁。
有了方法二,这种方法偏麻烦。
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。