For update带来的思考
For update or not
起源
之所以想写这个专题,是因为最近在做一个抢占任务的实现。假设数据库很多个任务,在抢占发生之前任务的状态都是FREE。现在假设同时有一堆抢占线程开始工作,抢占线程会查找数据库中状态为FREE的任务,并且将其状态置为BUSY,然后开始执行对应任务。执行完成之后,再将任务状态置为FINISH。任何任务都是不能被重复执行的,即必须保证所有任务都只能被一个线程执行。
笔者和人民群众一样,第一个想到的就是利用数据库的for update
实现悲观锁。这样肯定能够保证数据的强一致性,但是这样会大大影响效率,加重数据库的负担。想到之前看过的一篇文章https://www.cnblogs.com/bigben0123/p/8986507.html,文章里面有提到数据库引擎本身对更新的记录会行级上锁。这个行级锁的粒度非常细,上锁的时间窗口也最少,只有在更新记录的那一刻,才会对记录上锁。同时笔者也想到在前一家公司工作的时候,当时有幸进入到了核心支付组,负责过一段时间的账务系统。当时使用的是mysql的InnoDB引擎。记得当时的代码在往账户里面加钱的时候是没有加任何锁的,只有在从账户扣钱的时候才用for update
。所以这个问题应该有更加完美的答案......
探索之路
for update
的实现这里就不再做过多尝试了。这里笔者直接探索在没有for update
的时候高并发情况下是否会有问题。具体尝试的过程如下:
造测试数据
首先建立一个任务表,为了简单模拟,我们这里就只添加必要的字段。建表语句如下:
create table task(
ID NUMBER(10) NOT NULL,
TASK_RUN_STATUS NUMBER(4) NOT NULL
);
comment on table task is '互斥任务表';
comment on column task.ID is '主键ID.';
comment on column task.TASK_RUN_STATUS is '任务运行状态(1.初始待运行 2.运行中 3.运行完成).';
alter table task add constraint TASK_PK primary key (ID) using index;
为了方便测试,这里我们加入三条任务记录,插入任务记录的语句如下:
insert into task(id, task_run_status) values(0, 1);
insert into task(id, task_run_status) values(1, 1);
insert into task(id, task_run_status) values(2, 1);
模拟并发抢占
public class MultiThreadUpdate {
public static void main(String[] args) throws Exception {
Class.forName("oracle.jdbc.OracleDriver");
ExecutorService executorService = Executors.newFixedThreadPool(30);
List<Future<Void>> futures = new ArrayList<Future<Void>>();
// 每个ID开20个线程去并发更新数据
for (int i=0; i<20; i++) {
for (int j=0; j<3; j++) {
final int id = j;
futures.add(executorService.submit(new Callable<Void>() {
public Void call() throws Exception {
Connection con = DriverManager.getConnection("jdbc:oracle:thin:@localhost:1521:orcl", "czbank", "123456");
// con.setAutoCommit(false); // 不自动提交事务
PreparedStatement pstm = con.prepareStatement("update task set TASK_RUN_STATUS = ? where id = ? and TASK_RUN_STATUS = ?");
pstm.setInt(1, 2);
pstm.setInt(2, id);
pstm.setInt(3, 1);
int upRec = pstm.executeUpdate();
// 打印更新的记录条数
System.out.println("Thread:" + Thread.currentThread().getName() + " updated(id=" + id + "):" + upRec + " records...");
// Thread.sleep(1000); // 在事务提交之前,其线程都会阻塞直到对特定记录的更新提交
// con.commit();
con.close();
pstm.close();
return null;
}
}));
}
}
executorService.shutdown();
}
}
最终程序的输出结果如下:
Thread:pool-1-thread-9 updated(id=2):0 records...
Thread:pool-1-thread-15 updated(id=2):0 records...
Thread:pool-1-thread-22 updated(id=0):0 records...
Thread:pool-1-thread-28 updated(id=0):0 records...
Thread:pool-1-thread-14 updated(id=1):0 records...
Thread:pool-1-thread-17 updated(id=1):0 records...
Thread:pool-1-thread-26 updated(id=1):0 records...
Thread:pool-1-thread-30 updated(id=2):0 records...
Thread:pool-1-thread-29 updated(id=1):0 records...
Thread:pool-1-thread-27 updated(id=2):0 records...
Thread:pool-1-thread-5 updated(id=1):0 records...
Thread:pool-1-thread-23 updated(id=1):0 records...
Thread:pool-1-thread-21 updated(id=2):1 records...
Thread:pool-1-thread-1 updated(id=0):1 records...
Thread:pool-1-thread-6 updated(id=2):0 records...
Thread:pool-1-thread-8 updated(id=1):1 records...
Thread:pool-1-thread-10 updated(id=0):0 records...
Thread:pool-1-thread-13 updated(id=0):0 records...
Thread:pool-1-thread-4 updated(id=0):0 records...
Thread:pool-1-thread-19 updated(id=0):0 records...
Thread:pool-1-thread-16 updated(id=0):0 records...
Thread:pool-1-thread-2 updated(id=1):0 records...
Thread:pool-1-thread-11 updated(id=1):0 records...
Thread:pool-1-thread-7 updated(id=0):0 records...
Thread:pool-1-thread-25 updated(id=0):0 records...
Thread:pool-1-thread-3 updated(id=2):0 records...
Thread:pool-1-thread-18 updated(id=2):0 records...
Thread:pool-1-thread-12 updated(id=2):0 records...
Thread:pool-1-thread-20 updated(id=1):0 records...
Thread:pool-1-thread-24 updated(id=2):0 records...
Thread:pool-1-thread-15 updated(id=2):0 records...
Thread:pool-1-thread-9 updated(id=0):0 records...
Thread:pool-1-thread-22 updated(id=1):0 records...
Thread:pool-1-thread-30 updated(id=0):0 records...
Thread:pool-1-thread-5 updated(id=1):0 records...
Thread:pool-1-thread-17 updated(id=2):0 records...
Thread:pool-1-thread-26 updated(id=0):0 records...
Thread:pool-1-thread-29 updated(id=1):0 records...
Thread:pool-1-thread-27 updated(id=2):0 records...
Thread:pool-1-thread-28 updated(id=0):0 records...
Thread:pool-1-thread-21 updated(id=1):0 records...
Thread:pool-1-thread-1 updated(id=2):0 records...
Thread:pool-1-thread-14 updated(id=0):0 records...
Thread:pool-1-thread-2 updated(id=1):0 records...
Thread:pool-1-thread-16 updated(id=0):0 records...
Thread:pool-1-thread-4 updated(id=2):0 records...
Thread:pool-1-thread-13 updated(id=1):0 records...
Thread:pool-1-thread-19 updated(id=2):0 records...
Thread:pool-1-thread-6 updated(id=0):0 records...
Thread:pool-1-thread-8 updated(id=1):0 records...
Thread:pool-1-thread-10 updated(id=2):0 records...
Thread:pool-1-thread-23 updated(id=0):0 records...
Thread:pool-1-thread-11 updated(id=1):0 records...
Thread:pool-1-thread-7 updated(id=2):0 records...
Thread:pool-1-thread-25 updated(id=0):0 records...
Thread:pool-1-thread-3 updated(id=1):0 records...
Thread:pool-1-thread-18 updated(id=2):0 records...
Thread:pool-1-thread-12 updated(id=0):0 records...
Thread:pool-1-thread-20 updated(id=1):0 records...
Thread:pool-1-thread-24 updated(id=2):0 records...
可以看到,即使在没有显示使用事务的情况下,多线程并发执行也能够保证某一条数据的更新只被执行一次。
最终任务设计
通过上面的测试例子,已经验证了我的猜想。接下来就是如何设计抢占任务的执行步骤了。废话不多说,直接上基本代码:
public void runMutexTasks(MutexTaskDto runCond) throws Exception {
// STEP1: 先去查找待执行的互斥任务
runCond.setTaskRunStatus(Enums.MutexTaskRunStatus.WAIT_RUN.getKey()); // 待运行
runCond.setPhysicsFlag(Enums.TaskStatus.NORMAL.getKey()); // 正常状态(未废弃)
PageInfo<MutexTaskDto> runnableTasks = MutexTaskService.pagingQueryGroupByTaskId(0, 0, runCond);
if (CollectionUtils.isEmpty(runnableTasks.getRows())) {
LOGGER.debug("根据条件未找到待执行的互斥任务,跳过执行......");
return;
}
// STEP2: 分别尝试执行
List<MutexTaskDto> runTasks = null;
Collections.shuffle(runnableTasks.getRows()); // 打乱顺序
for (MutexTaskDto oneTask : runnableTasks.getRows()) {
runTasks = mutexTaskService.selectRunnableTaskByTaskId(oneTask.getTaskId());
if (CollectionUtils.isEmpty(runTasks)) {
LOGGER.info("互斥任务ID【{}】已不是待运行状态,跳过任务执行......", oneTask.getTaskId());
continue;
}
// STEP3: 运行任务
MutexTaskDto updateCond = new MutexTaskDto();
updateCond.setTaskRunStatus(Enums.MutexTaskRunStatus.RUN_SUCCESS.getKey());
updateCond.setTaskPreStatus(Enums.MutexTaskRunStatus.RUNNING.getKey());
updateCond.setTaskId(oneTask.getTaskId());
try {
runTasks(runTasks);
} catch(Exception e) {
updateCond.setRunRemark(getErrorMsg(e));
updateCond.setTaskRunStatus(Enums.MutexTaskRunStatus.RUN_FAILED.getKey());
mutexTaskService.updateByTaskId(updateCond);
// 这里只打印失败结果,具体失败信息需要上层调用方法日志打印出来
LOGGER.error("互斥任务ID【{}】执行失败!", oneTask.getTaskId());
throw e;
}
mutexTaskService.updateByTaskId(updateCond);
LOGGER.info("互斥任务ID【{}】执行成功......", oneTask.getTaskId());
Thread.sleep(1000); // 抢到了一个节点执行权限,此处暂停1s,给其他机器机会
}
}
// 其中mutexTaskService的selectRunnableTaskByTaskId方法如下:
// 不使用事务,利用数据库引擎自身的行级锁
public List<MutexTaskDto> selectRunnableTaskByTaskId(String taskId) {
// STEP1: 先用查询数据(一个taskID可能对应多条记录,对应不同的参数)
List<MutexTaskModle> mutexTaskModles = this.mutexTaskDao
.selectByTaskId(taskId);
if (CollectionUtils.isEmpty(mutexTaskModles)) {
return Collections.emptyList();
}
// STEP2: 更新数据(使用数据库引擎自身所带的行级锁)
MutexTaskModle updateInfo = new MutexTaskModle();
updateInfo.setTaskRunStatus(2);
updateInfo.setTaskPreStatus(1);
updateInfo.setTaskId(taskId);
int updateCount = cleaningMutexTaskDao.updateByTaskId(updateInfo);
if (updateCount <= 0) {
LOGGER.info("找到待执行的互斥任务,但是更新任务为执行中失败......");
return Collections.emptyList();
}
// STEP3: 前面两项都校验过,则确认当前任务列表是可以执行的
List<MutexTaskDto> mutexTasks = BeanConvertUtils.convertList(mutexTaskModles,
MutexTaskDto.class);
return mutexTasks;
}
关键点就在于第58
行的cleaningMutexTaskDao.updateByTaskId(updateInfo);
。该语句对应的SQL大致为:
update TASK set task_status = ? where task_id = ? and task_tatus = ?
其中task_id为表的主键,且启用了唯一索引。
总结
这个问题刚开始笔者想到的解决方案就是使用for update
。但内心总觉得这不是最佳方案,想起以前做过的项目还有看过的文章,却也总是不太确定。最终还是自己动手写了个测试用例"释怀"了内心的疑惑。最终也顺利地想出了这个"完美"的实现。不得不承认:实践是检验真理的唯一标准!工作到现在,越来越觉得大家觉得最好的实现不一定就是最好的,大家认为的最高效的方法不一定就是最高效的。很多事情没有绝对,就像写代码一样,没有绝对的好代码。
当然这不是鼓励大家随便写代码,笔者想说的是:做软件就像做学问。不能纯粹地拿别人的结论奉为圣经。遇到问题要多思考,才会有自己的沉淀。思考之后要多行动,才不会仅仅停留在思想的巨人,行动的矮子。当然,行动之后也要多多整理出来,就像笔者这样,奉献社会,方便你我他......(一脸无语)
For update带来的思考的更多相关文章
- 一次flume exec source采集日志到kafka因为单条日志数据非常大同步失败的踩坑带来的思考
本次遇到的问题描述,日志采集同步时,当单条日志(日志文件中一行日志)超过2M大小,数据无法采集同步到kafka,分析后,共踩到如下几个坑.1.flume采集时,通过shell+EXEC(tail -F ...
- 用Vue自己造个组件轮子,以及实践背后带来的思考
前言 首先,向大家说声抱歉.由于之前的井底之蛙,误认为Vue.js还远没有覆盖到二三线城市的互联网小厂里.现在我错了,从我司的前端技术选型之路便可见端倪.以太原为例,已经有不少公司陆续开始采用Vue. ...
- 第6届蓝桥杯javaA组第7题,牌型种数,一道简单的题带来的思考
题目: 小明被劫持到X赌城,被迫与其他3人玩牌. 一副扑克牌(去掉大小王牌,共52张),均匀发给4个人,每个人13张. 这时,小明脑子里突然冒出一个问题: 如果不考虑花色,只考虑点数,也不考虑自己得到 ...
- 原生javascript难点总结(1)---面向对象分析以及带来的思考
------*本文默认读者已有面向对象语言(OOP)的基础*------ 我们都知道在面向对象语言有三个基本特征 : 封装 ,继承 ,多态.而js初学者一般会觉得js同其他类C语言一样,有类似于Cl ...
- 微信小程序开发带来的思考
若无小程序开发经验,可先阅读 玩转微信小程序 一文. 微信小程序正式上线已有几周时间,相信它的开发模式你已烂熟于胸,可能你也有所疑问,我竟能用 web 语言开发出如此流畅的几乎原生体验的应用.可能你又 ...
- 从壹开始前后端分离 [.netCore 填坑 ] 三十四║Swagger:API多版本控制,带来的思考
前言 大家周二好呀,.net core + Vue 这一系列基本就到这里差不多了,今天我又把整个系列的文章下边的全部评论看了一下(我是不是很负责哈哈),提到的问题基本都解决了,还有一些问题,已经在QQ ...
- Gevent 性能和 gevent.loop 的运用和带来的思考
知乎自己在底层造了非常多的轮子,而且也在服务器部署方面和数据获取方面广泛使用 gevent 来提高并发获取数据的能力.现在开始我将结合实际使用与测试慢慢完善自己对 gevent 更全面的使用和扫盲. ...
- mysql for update 高并发 死锁研究
mysql for update语句 https://www.cnblogs.com/jtlgb/p/8359266.html For update带来的思考 http://www.cnblo ...
- 64位进程调用32位dll的解决方法 / 程序64位化带来的问题和思考
最近做在Windows XP X64,VS2005环境下做32位程序编译为64位程序的工作,遇到了一些64位编程中可能遇到的问题:如内联汇编(解决方法改为C/C++代码),long类型的变化,最关键的 ...
随机推荐
- boot分区剩余空间不足
Linux boot分区用于存放内核文件以及Linux一些启动配置文件,一般情况下分区大小为500M足够使用,如果出现空间不足的问题可以使用以下方法来解决. 查看已经安装的内核 dpkg --ge ...
- vim 安装vim-javascript插件--Vundle管理
最近看了一下node.js,但是写的时候,vim对js没有很好的提示.于是就安装插件来处理,准备安装vim-javascript.但是安装github上面的插件时,推荐用Vundle和pathogen ...
- Spring Boot 2 实践记录之 MyBatis 集成的启动时警告信息问题
按笔者 Spring Boot 2 实践记录之 MySQL + MyBatis 配置 中的方式,如果想正确运行,需要在 Mapper 类上添加 @Mapper 注解. 但是加入此注解之后,启动时会出现 ...
- Java计算手机九宫格锁屏图案连接9个点的方案总数
(一)问题 九宫格图案解锁连接9个点共有多少种方案? (二)初步思考 可以把问题抽象为求满足一定条件的1-9的排列数(类似于“八皇后问题”),例如123456789和987654321都是合法的(按照 ...
- IIS7 上传时出现'ASP 0104 : 80004005'错误
这个错误本身说的是上传的文件的大小超过IIS所设置的默认值,一般为200KB,压缩文件是个下下之选,我还真这么干过.后来了解到通过更改IIS对上传文件的默认大小设置,来实现上传. 下面说一下具体步骤: ...
- ASP.NET Core 2 学习笔记(二)生命周期
要了解程序的运行原理,就要先知道程序的进入点及生命周期.以往ASP.NET MVC的启动方式,是继承 HttpApplication 作为网站开始的进入点,而ASP.NET Core 改变了网站的启动 ...
- 构建NetCore应用框架之实战篇(六):BitAdminCore框架架构小结
本篇承接上篇内容,如果你不小心点击进来,建议从第一篇开始完整阅读,文章内容继承性连贯性. 构建NetCore应用框架之实战篇系列 一.小结 1.前面已经完成框架的第一个功能,本篇做个小结. 2.直接上 ...
- C#获取微信二维码显示到wpf
微信的api开放的二维码是一个链接地址,而我们要将这个二维码显示到客户端.方式很多,今天我们讲其中一种. /// <summary> /// 获取图片路径 /// </summary ...
- C# 32位程序,申请大内存,附dome(wpf),亲测可用
1.我是vs2017,在选装vs的时候,需要安装c++模块,因为申请大内存的必要exe存放在vc的某个目录(下面会给出详细的地址)下的 2.安装完成在vs的安装目录可找到这个文件,我是社区版本的,如果 ...
- 听补天漏洞审核专家实战讲解XXE漏洞
对于将“挖洞”作为施展自身才干.展现自身价值方式的白 帽 子来说,听漏洞审核专家讲如何挖掘并验证漏洞,绝对不失为一种快速的成长方式! XXE Injection(XML External Entity ...