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("${ister.prefix}")
private String REDIS_REGISTER_KEY_PREFIX;
// 锁过期时间:即获取锁后线程能进⾏操作的最长时间,超过该时间后锁⾃动被释放(失效),别⼈可
以重新开始获取锁进⾏对应操作
// 设定锁过期时间是为了防⽌某线程成功获取锁后在执⾏任务过程中发⽣意外挂掉了造成锁永远⽆法被释放
@Value("${ister.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 (ists(redisKey)){
return false;
}
// 开始尝试加锁,注意此处需使⽤SETNX指令(因为可能存在多个线程同时到达这⼀步开始加锁,使⽤SETNX来确保有且仅有⼀个设置成功返回)
boolean setLock = redisService.setNX(redisKey, null);
// 开始尝试设置锁过期时间,到了过期时间线程还没有释放锁的话,由保存锁的Redis来确保锁最终被释放,以免出现死锁
// 锁过期时间的设置上,可以评估线程执⾏任务的正常⽤时,在正常⽤时的基础上稍微再⼤⼀点
boolean setExpire = pire(redisKey, REDIS_REGISTER_TIMEOUT);
// 设置锁和设置过期时间均成功时才认为当前线程获取锁成功,否则认为获取锁失败
if (setLock && setExpire) {
return true;
}
// 当发⽣设置锁成功,但设置过期时间失败的情况时,⼿动清除刚刚设置的锁Key
redisService.del(redisKey);
return false;
}
/**
* 删除设备注册时的分布式锁
* @param deviceMacAddress 设备的Mac地址
*/大一java期末考试题及答案
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 = MacAddress());
if (!registerLock) {
log.info("获取设备注册锁失败,当前注册请求失败!");
ateBussinessErrorResult();
}
// 加锁成功,开始注册设备
ReturnObj result = registerDevice(deviceInfo);
// 注册设备完成,删除锁
lockUtil.MacAddress());
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 常见的并发问题处理⽅法总结的详细内容,更多关于Java 并发问题的资料请关注其它相关⽂章!
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论