Java工作中常见并发问题处理方法总结并发问题及解决办法,希望对大家有所帮助:grin:问题复现1.》设备Aの怪克隆”时间回到很久很久以前的一个深夜,当时我开发的多媒体广告播放控制系统刚刚投产上线,在公司开的第一家线下生鲜店,经过大大小小的几十家多媒体硬件设备连接正常网络,我正在一个一个注册,连接到已经上线的多媒体广告播控系统中。注册过程简述如下:每个设备在系统中注册后,都会在数据库设备表中增加一条对应的记录,用于存储设备的各种信息。一切都在有条不紊地进行着,直到设备A的注册打破了默契的宁静……设备A注册完成后,突然发现数据库设备表中新增了两条记录,而且是两条完全相同的记录记录!开始以为自己眼花缭乱了……仔细一看,确实是新增了两个,而且连唯一设备标识(划线,待会儿测试)和创建时间都一模一样!看着屏幕,我陷入了沉思……为什么是两个?在我的注册逻辑中,在放入数据库之前,我会检查该设备是否已经存在于数据库中。如果存在,我就更新已有的,如果不存在,我就添加。所以我想不通。按照这个逻辑,第二条一模一样的数据是从哪里来的呢?2.真相背后的并发请求经过一番排查和思考,我发现问题可能出在注册请求上。设备A向云端发送http注册请求时,可能会同时发送多个相同的请求。云服务器当时部署在多个Docker容器上。通过查看日志,发现有两个容器同时收到了设备A的注册请求。由此我猜测:设备A同时发送了两个注册请求,这两个请求同时发送到云端不同的容器。按照我的注册逻辑,两个容器收到注册请求后,去数据库的device表中查询。此时设备表中没有设备A的记录,所以两个容器都进行了新的操作。因为速度很快,所以这两条新记录都是在秒级创建的,没有表现出差异。3、并发new扩展既然并发new操作会出问题,那么并发update操作会不会有问题呢?解决并发加法的解决方法1.数据库唯一索引(UNIQUEINDEX)在数据库中建表时,为唯一字段(如上述设备唯一标识)创建唯一索引,或者合并后具有唯一性创建一个联合多个字段的唯一索引。这样,在并发添加的时候,只要一个添加成功,其他的添加操作就会因为数据库抛出异常(java.sql.SQLIntegrityConstraintViolationException)而失败。我们只需要处理添加失败即可。注意唯一索引的字段需要非空,因为当字段值为空时,唯一索引约束会失效。成功可以继续,否则添加失败。这样也可以解决并发插入带来的数据重复问题,但是分布式锁的引入也增加了系统的复杂度。如果要存储的数据上有唯一字段,建议使用唯一索引方式。在构建分布式锁的过程中,我们需要用到Redis。这里我们以用于设备注册的分布式锁为例。分布式锁简单问答:Q:锁到底是什么?A:锁其实是存储在Redis中的,是一个根据特定规则生成的字符串(例子中是固定前缀+唯一设备标识),也就是说每个设备在注册的时候都有自己对应的锁,因为lock只有一个,即使设备有多个相同的注册请求同时到来,也只有获取到锁的请求才能成功。Q:什么是获取锁?A:同一个设备,按照相同的规则生成的字符串(以下简称为Key)总是相同的。在执行新的操作之前,首先去Redis中查看Key是否存在。如果存在,说明获取锁失败;如果不存在,则将Key存储在Redis中。如果存储成功,则表示获取锁成功。如果存储失败,仍然意味着获取锁失败。问:锁是如何工作的?A:前面说了,同一个设备根据同一个规则生成的字符串(Key)总是一样的。当前线程在执行new操作之前,先检查Redis中是否存在该Key。如果存在,说明此时另一个线程已经成功获取到锁,正在做当前线程想做的new操作,所以当前线程不需要进行后续的操作(是的,你是多余的)。当Key不存在时,说明现在没有其他线程获取到锁,当前线程可以进行下一步——快速将Key保存到Redis中。当Key保存失败时,说明另一个线程抢占了Key,成功获取了锁,当前线程晚了一步,自己要做的工作被别人抢占了(当前线程可以退休)。当且仅当key也成功存储到Redis中,才说明当前线程终于成功获取到锁,可以安心进行了。添加了以下新操作。期间,其他想做同样new操作的线程,因为无法获得锁,只能退出。再见:wave:,记得在当前线程执行完后释放锁(从Redis中删除这个Key)。注册时使用的分布式锁代码如下:publicclassLockUtil{//一个简单封装redis底层set/get方法的工具类@AutowiredprivateRedisServiceredisService;//为锁生成一个固定的前缀,从配置文件中读取值@Value("${redis.register.prefix}")privateStringREDIS_REGISTER_KEY_PREFIX;//锁过期时间:即线程获得锁后可以操作的最长时间。过了这个时间后,锁自动释放(无效),其他人可以重新开始获取锁进行相应的操作//设置锁过期时间是为了防止一个线程在获取成功后,在任务执行过程中意外挂掉锁,导致锁永远不会被释放@Value("${redis.register.timeout}")privateLongREDIS_REGISTER_TIMEOUT;/***注册设备时获取分布式锁*@paramdeviceMacAddress设备的Mac地址*@return*/publicbooleangetRegisterLock(StringdeviceMacAddress){if(StringUtils.isEmpty(deviceMacAddress)){returnfalse;}//获取设备锁对应的字符串(Key)StringredisKey=getRegisterLockKey(deviceMacAddress);//开始尝试获取lock//如果当前任务lockkey已经存在,说明当前时间内有其他线程正在设备上执行任务,当前线程可以退出if(redisService.exists(redisKey)){returnfalse;}//开始尝试加锁,注意这里需要使用SETNX指令(因为可能有多个线程同时到达这一步开始加锁,使用SETNX保证一次且只有一次设置成功返回)booleansetLock=redisService.setNX(redisKey,null);//开始尝试设置锁过期时间,如果过期时间线程还没有释放锁,持有锁的Redis会确保锁finallyreleasedtoavoiddeadlock//在锁过期时间的设置中,可以评估线程执行任务的正常耗时,在正常耗时boolea的基础上略大nsetExpire=redisService.expire(redisKey,REDIS_REGISTER_TIMEOUT);//只有锁设置和过期时间都设置成功,当前线程才获取锁成功,否则认为获取锁失败if(setLock&&setExpire){returntrue;}//当锁设置成功,但设置过期时间失败时,手动清除刚刚设置的锁KeyredisService.del(redisKey);returnfalse;}/***注册设备时删除分布式锁*@paramdeviceMacAddress设备的mac地址*/publicvoiddelRegisterLock(StringdeviceMacAddress){redisService.del(getRegisterLockKey(deviceMacAddress));}/***在设备注册时获取分布式锁的key*@paramdeviceMacAddress设备的mac地址(每个设备的mac地址是唯一的)*@return*/privateStringgetRegisterLockKey(StringdeviceMacAddress){returnREDIS_REGISTER_KEY_PREFIX+"_"+deviceMacAddress;}}正常使用锁的例子注册逻辑如下:publicReturnObjregistry(@RequestBodyStringdevice){DevicesdeviceInfo=JSON.parseObject(device,Devices.class);//开始注册前添加LockbooleanregisterLock=lockUtil.getRegisterLock(deviceInfo.getMacAddress());if(!registerLock){log.info("获取设备注册锁失败,当前注册请求失败!");returnReturnObj.createBussinessErrorResult();}//加锁成功,开始注册设备ReturnObjresult=registerDevice(deviceInfo);//注册设备,删除锁Util.delRegisterLock(deviceInfo.getMacAddress());returnresult;}解决并发更新1.并发更新真的会出问题吗?当同时更新或者逐条更新对业务没有影响时,不需要做任何处理,以免白白增加系统的复杂度2.乐观锁可以通过乐观锁避免重复更新,即:在数据库表中添加一个“版本号”(version)字段,在做update操作之前查询记录,记下查询到的版本号,然后判断之前查询到的版本号是否与当前数据库一致在实际更新操作过程中,记录中记录的版本号是一致的。如果一致,说明当前线程从查询到更新这段时间没有其他线程更新过这条记录;如果不一致,说明这段时间有其他线程修改了这条记录。当前线程的更新操作已经不安全,只能放弃。判断SQL示例:updatea_tablesetname=test1,age=12,versionversion=version+1whereid=3andversion=1乐观锁是通过版本号来判断从数据库读取的数据在上次更新的时刻是否被其他人修改过。它的效率高于悲观锁,因为在当前线程查询到最后一次更新之间的这段时间里,其他线程可以照常读取同一条记录,可以先更新它。悲观锁悲观锁与乐观锁正好相反。当当前线程查询要更新的数据时,数据被锁定,在更新完成之前不允许其他线程修改数据。通过使用select...forupdate告诉数据库“我将很快更新此数据,帮我锁定它”。注意:FORUPDATE只适用于InnoDB,必须在一个事务中生效。当查询条件有明确的主键且有这样一条记录时,就是行锁(rowlock,只对根据查询条件定位到的数据行进行加锁),而查询条件没有主键或主键不清楚,是表锁(表锁,锁定整个表,会导致整个表的数据在锁定期间不能更改),所以在使用悲观锁的时候,最好明确找到某一行或几行。行,不触发全表锁
