Java工作中常见的并发问题处理方法总结

好像挺久没有写博客了,趁着这段时间比较闲,特来总结一下在业务系统开发过程中遇到的并发问题及解决办法,希望能帮到大家

问题复现

1. “设备Aの奇怪分身”

时间回到很久很久以前的一个深夜,那时我开发的多媒体广告播放控制系统刚刚投产上线,公司开出的第一家线下生鲜店里,几十个大大小小的多媒体硬件设备正常联网后,正由我一台一台的注册及接入到已经上线的多媒体广告播控系统中。

注册过程简述如下:

每一个设备注册到系统中后,相应的在数据库设备表中都会新增一条记录,来存储这个设备的各项信息。

本来一切都有条不紊的进行着,直到设备A的注册打破了这默契的宁静……

设备A注册完成后,我突然发现,数据库设备表中,新增了两条记录,而且是两条一模一样的记录!

我开始以为自己眼花了……

仔细一看,确确实实是新增了两条,而且连设备唯一标识(划横线,后面要考)和创建时间都一模一样!

看着屏幕,我陷入了沉思……

为什么会有两条呢?

在我的注册逻辑里,落库之前会先查一遍数据库该设备是否已存在,如果存在就更新已有的,不存在才新增。

所以我百思不得其解,按这个逻辑,第二条一模一样的数据是哪来的?

2. 真相背后的并发请求

经过一番排查及思考,我发现问题可能就出在注册请求上。

设备A在向云端发送http注册请求时,可能会同时发送多个相同请求。

云服务器当时部署在多台Docker容器上,通过查看日志发现,有两台容器同时收到了来自设备A的注册请求。

由此,我推测:

设备A同时发送了两个注册请求,这两个请求分别在同一时间打到了云端的不同容器上,按照我的注册逻辑,这两个容器接收到注册请求后,同时去查询了数据库的设备表,这时候设备表里还没有设备A的记录,所以两台容器都执行了新增的操作,因为速度很快,所以这两条新增记录在精确到秒的创建时间上,并没有体现出差别。

3. 并发新增的延伸

既然并发的新增操作会产生问题,那么并发的更新操作是否会有问题呢?

解决方法

解决并发新增

1. 数据库唯一索引(UNIQUE INDEX)

在数据库建表的时候,通过对具有唯一性的字段(比如上述的设备唯一标识)创建唯一索引,或对组合起来后就具备唯一性的几个字段创建联合唯一索引。

这样在并发新增时,只要有一个新增成功,其他的新增操作都会因为数据库抛出的异常(java.sql.SQLIntegrityConstraintViolationException)而失败,我们只需要处理好新增失败的情况就行了。

注意唯一索引的字段需要非空,因为字段值为空时会导致唯一索引约束失效

2. java分布式锁

通过在程序中引入分布式锁,在进行新增操作前需要先获取分布式锁,获取成功才能继续,否则新增失败。

这样也能解决并发插入带来的数据重复问题,只是引入分布式锁的同时也增加了系统的复杂性,如果要落库的数据上有唯一性字段的话,还是推荐采用唯一索引的方法。

在构建分布式锁的过程中,我们需要用到Redis,这里以设备注册时使用的分布式锁为例。

分布式锁简单问答:

Q:锁究竟是什么?

A:锁实质上是存储在Redis中,基于特定规则生成的一个字符串(示例里是固定前缀+设备唯一标识),相当于每个设备注册的时候都有自己对应的一把锁,因为锁只有一把,即使该设备有多个相同的注册请求同时到来,也只有其中获取到那把锁的那一个请求能成功走下去。

Q:什么是获取锁?

A:同一个设备,基于相同的规则生成的字符串(后文以Key代称该字符串)总是相同的,在执行新增操作前,先去Redis中查询这个Key是否存在,如果已存在,就意味着获取锁失败;如果不存在,就将这个Key现存到Redis中,如果存储成功,表示获取锁成功,如果存储失败,还是意味着获取锁失败。

Q:锁是怎么工作的?

A:前面说过,同一个设备,基于相同的规则生成的字符串(Key)总是相同的,在当前线程执行新增操作前,先在Redis中查询这个Key是否存在,如果已存在,表示此时已经有别的线程成功获取了锁,正在做当前线程想要做的新增操作,则当前线程不需要进行后续操作了(是的,你是多余的)

当这个Key不存在时,表示现在还没有其他线程获得锁,则当前线程可以继续进行下一步操作——在Redis中赶紧存入这个Key,当这个Key存储失败时,意味着有别的线程抢先存入了Key成功获取了锁,当前线程晚了一步,想做的工作被别人抢先做了(当前线程可以退下了)

当且仅当在Redis中存入这个Key也成功时,表示当前线程终于获取锁成功,可以安心进行后面的新增操作了,期间别的想做相同新增操作的线程因为获取不到锁,只能全都退场拜拜,当前线程执行完后要记得释放锁(从Redis中删除这个Key)。

注册时使用的分布式锁代码如下:

public class LockUtil {

    // 对redis底层set/get方法进行了简单封装的工具类
@Autowired
private RedisService redisService; // 生成锁的固定前缀,从配置文件读取值
@Value("${redis.register.prefix}")
private String REDIS_REGISTER_KEY_PREFIX; // 锁过期时间:即获取锁后线程能进行操作的最长时间,超过该时间后锁自动被释放(失效),别人可以重新开始获取锁进行对应操作
// 设定锁过期时间是为了防止某线程成功获取锁后在执行任务过程中发生意外挂掉了造成锁永远无法被释放
@Value("${redis.register.timeout}")
private Long REDIS_REGISTER_TIMEOUT; /**
* 获取设备注册时的分布式锁
* @param deviceMacAddress 设备的Mac地址
* @return
*/
public boolean getRegisterLock(String deviceMacAddress) {
if (StringUtils.isEmpty(deviceMacAddress)) {
return false;
} // 获取设备对应锁的字符串(Key)
String redisKey = getRegisterLockKey(deviceMacAddress); // 开始尝试获取锁
// 如果当前任务锁key已存在,则表示当前时间内有其他线程正在对该设备执行任务,当前线程可以退下了
if (redisService.exists(redisKey)){
return false;
} // 开始尝试加锁,注意此处需使用SETNX指令(因为可能存在多个线程同时到达这一步开始加锁,使用SETNX来确保有且仅有一个设置成功返回)
boolean setLock = redisService.setNX(redisKey, null); // 开始尝试设置锁过期时间,到了过期时间线程还没有释放锁的话,由保存锁的Redis来确保锁最终被释放,以免出现死锁
// 锁过期时间的设置上,可以评估线程执行任务的正常用时,在正常用时的基础上稍微再大一点
boolean setExpire = redisService.expire(redisKey, REDIS_REGISTER_TIMEOUT); // 设置锁和设置过期时间均成功时才认为当前线程获取锁成功,否则认为获取锁失败
if (setLock && setExpire) {
return true;
} // 当发生设置锁成功,但设置过期时间失败的情况时,手动清除刚刚设置的锁Key
redisService.del(redisKey);
return false;
} /**
* 删除设备注册时的分布式锁
* @param deviceMacAddress 设备的Mac地址
*/
public void delRegisterLock(String deviceMacAddress) {
redisService.del(getRegisterLockKey(deviceMacAddress));
} /**
* 获取设备注册时分布式锁的key
* @param deviceMacAddress 设备mac地址(每个设备的mac地址都是唯一的)
* @return
*/
private String getRegisterLockKey(String deviceMacAddress) {
return REDIS_REGISTER_KEY_PREFIX + "_" + deviceMacAddress;
}
}

在正常的注册逻辑中使用锁的示例如下:

    public ReturnObj registry(@RequestBody String device){
Devices deviceInfo = JSON.parseObject(device, Devices.class); // 开始注册前加锁
boolean registerLock = lockUtil.getRegisterLock(deviceInfo.getMacAddress());
if (!registerLock) {
log.info("获取设备注册锁失败,当前注册请求失败!");
return ReturnObj.createBussinessErrorResult();
} // 加锁成功,开始注册设备
ReturnObj result = registerDevice(deviceInfo); // 注册设备完成,删除锁
lockUtil.delRegisterLock(deviceInfo.getMacAddress()); return result;
}

解决并发更新

1. 并发更新真的会引发问题吗?

当发生同时更新或一前一后更新的情况对业务并无影响的时候,那就无需进行任何处理,免得徒劳增加系统复杂度。

2. 乐观锁

通过乐观锁的方式可以避免重复更新,即:在数据库表中加入一个“版本号”(version)的字段,在做更新操作前先查询记录,记下查询出的版本号,之后在实际更新操作的时候判断此前查询出的版本号是否与当前数据库中该条记录的版本号一致,如果一致,说明在当前线程从查询到更新这段时间里,没有其他线程更新这条记录;如果不一致,说明再此期间已经有其他线程更改了这条记录,当前线程的更新操作已经不安全了,只能放弃。

判断SQL示例:

update a_table set name=test1, age=12, version=version+1 where id = 3 and version = 1

乐观锁通过版本号的方式,在最后更新的关头才判断自己之前从数据库读取的数据有没有被别人修改,其效率高于悲观锁,因为在当前线程查询和最后更新前的这段时间里,其他线程可以照常读取这同一条记录,且可以抢先更新。

悲观锁

悲观锁与乐观锁恰好相反,在当前线程查询这条待更新的数据时,就锁住了这条数据,不允许在自己更新完成前有其他线程修改数据。

通过使用 select … for update 来告诉数据库“我马上要更新这条数据,把它给我锁起来”。

注意:FOR UPDATE 仅适用于InnoDB,且必须在事务中才能生效,当查询条件有明确主键且有此记录时为行锁定(row lock,只锁定根据查询条件定位到的这一行数据),查询条件无主键或主键不明确时为表锁定(table lock,锁定全表,会造成全表的数据在锁定期都无法被更改),所以使用悲观锁时查询条件最好能明确定位到某一行或几行,不要引发全表锁定

Java工作中的并发问题处理方法总结的更多相关文章

  1. SuperDiamond在JAVA项目中的三种应用方法实践总结

    SuperDiamond在JAVA项目中的三种应用方法实践总结 1.直接读取如下: @Test public static void test_simple(){ PropertiesConfigur ...

  2. Java编程中获取键盘输入实现方法及注意事项

    Java编程中获取键盘输入实现方法及注意事项 1. 键盘输入一个数组 package com.wen201807.sort; import java.util.Scanner; public clas ...

  3. Java集合中Map接口的使用方法

    Map接口 Map提供了一种映射关系,其中的元素是以键值对(key-value)的形式存储的,能够实现根据key快速查找value: Map中的键值对以Entry类型的对象实例形式存在: 建(key值 ...

  4. Java 8 中为什么要引出default方法

    (原) default方法是java 8中新引入进的,它充许接口中除了有抽象方法以外,还可以拥用具有实现体的方法,这一点跟jdk8之前的版本已经完全不一样了,为什么要这样做呢? 拿List接口举例,在 ...

  5. Java 8中Collection转为Map的方法

    Java 8中java.util.stream.Collectors提供了几个方法可用于把Collection转为Map结构,本文记录了个人对其中三个的理解. Method Return Type g ...

  6. Java工作中常用到的工具

    刚看但网上一篇Java最流行工具的文章,结合自己日常应用总结一下开发工作中常用到的工具. 一,Java版本,jdk8,也是Java普及最快的版本,除了本身性能提升外,新增的函数式编程特性也让开发效率更 ...

  7. java 接口中的成员变量与方法

    java接口中变量的默认修饰符为 public static final int i = 3; 相当于 public static final int i = 3; java接口中方法的默认修饰符为 ...

  8. 我要告诉你:java接口中可以定义private私有方法

    在传统的Java编程中,被广为人知的一个知识点是:java Interface接口中不能定义private私有方法.只允许我们定义public访问权限的方法.抽象方法或静态方法.但是从Java 9 开 ...

  9. Java程序中调用Python脚本的方法

    在程序开发中,有时候需要Java程序中调用相关Python脚本,以下内容记录了先关步骤和可能出现问题的解决办法. 1.在Eclipse中新建Maven工程: 2.pom.xml文件中添加如下依赖包之后 ...

随机推荐

  1. CentOS8_在线安装_网络源_网络镜像源填写格式_以及其他笔记

    CentOS8_在线安装_网络源_网络镜像源填写格式_以及其他笔记 转载注明来源: 本文链接 来自osnosn的博客,写于 2020-10-1. 参考: Centos8.0.1905 在线安装源选择 ...

  2. 杭电2734----Quicksum(C++)(数字与字符的关系)

    Quicksum Time Limit: 2000/1000 MS (Java/Others)    Memory Limit: 32768/32768 K (Java/Others) Total S ...

  3. 《犬夜叉2021》我想通过Binder找到你

    前言 本故事纯属虚构,如有不通顺的逻辑请轻喷.️ <犬夜叉2021> 第一章:我还能找到你吗,阿篱 犬夜叉和奈落大决战之后,四魂之玉.食骨之井消失,谁也不知道去了哪,而犬夜叉和阿篱再次被分 ...

  4. Spring Boot Security 国际化 多语言 i18n 趟过巨坑

    网上很多的spring boot国际化的文章都是正常情况下的使用方法 如果你像我一样用了Spring Security 那么在多语言的时候可能就会遇到一个深渊 Spring Security里面的异常 ...

  5. Flutter 布局类组件:流式布局(Wrap和Flow)

    前言 把超出屏幕显示范围会自动折行的布局称为流式布局.Flutter中通过Wrap和Flow来支持流式布局,将Row换成Wrap后溢出部分则会自动折行. Wrap 接口描述 Wrap({ Key ke ...

  6. Python列表推导式玩法

    前言 列表做为python的基础,是必须学习的语法之一.一些基础的之前已经是反复温习和使用了,今天我们来学习它的进阶版-->列表推导式. 列表推导式: 优点:是将所有的值一次性加载到内存中,相比 ...

  7. SQLI-LABS复现通关

    sql-lab 复现通关(深入学习) less-1 基于错误的单引号字符串 - 正常访问 127.0.0.1/?id=1 - 添加 ' 返回报错信息:You have an error in your ...

  8. 不要把file,process或者super权限授予管理员以外的账号

    file权限的主要作用是通过select ....into outfile 写到服务器上具有写权限的目录下,作为文本格式存放,具有权限的目录也就是启动mysql时的用户权限目录.(没有理解) 可以将有 ...

  9. 计算机之路 -MySQL 初学

    照着电脑学了一天终于把MySQL装上了. 明天打算重新装一次 然后再自己记录一下步骤

  10. 推荐几个学习Python的免费网站

    想要学好Python,只靠看Python相关的书籍是远远不够的!今天为大家分享几个实用的Python学习网站. 欢迎各位热爱Python的小伙伴进群交流:610380249群里有大佬哦,而且很热心,群 ...