百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术教程 > 正文

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

csdh11 2025-04-10 22:03 10 浏览

问题复现

每一个设备注册到系统中后,相应地在数据库设备表中都会新增一条记录,来存储这个设备的各项信息。
本来一切都有条不紊地进行着,直到设备A的注册打破了这默契的宁静……
设备A注册完成后,我突然发现,数据库设备表中,新增了两条记录,而且是两条一模一样的记录!
我开始以为自己眼花了……
仔细一看,确确实实是新增了两条,而且连设备唯一标识(划横线,后面要考)和创建时间都一模一样!
看着屏幕,我陷入了沉思……
为什么会有两条呢?
在我的注册逻辑里,落库之前会先查一遍数据库该设备是否已存在,如果存在就更新已有的,不存在才新增。
所以我百思不得其解,按这个逻辑,第二条一模一样的数据是哪来的?

. 真相背后的并发请求

经过一番排查及思考,我发现问题可能就出在注册请求上。
设备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项目中日志系统最佳实践:从入门到精通

探索Java项目中日志系统最佳实践:从入门到精通在现代软件开发中,日志系统如同一位默默无闻却至关重要的管家,它记录了程序运行中的各种事件,为我们排查问题、监控性能和优化系统提供了宝贵的依据。在Java...

用了这么多年的java日志框架,你真的弄懂了吗?

在项目开发过程中,有一个必不可少的环节就是记录日志,相信只要是个程序员都用过,可是咱们自问下,用了这么多年的日志框架,你确定自己真弄懂了日志框架的来龙去脉嘛?下面笔者就详细聊聊java中常用日志框架的...

物理老师教你学Java语言(中篇)(物理专业学编程)

第四章物质的基本结构——类与对象...

一文搞定!Spring Boot3 定时任务操作全攻略

各位互联网大厂的后端开发小伙伴们,在使用SpringBoot3开发项目时,你是否遇到过定时任务实现的难题呢?比如任务调度时间不准确,代码报错却找不到方向,是不是特别头疼?如今,随着互联网业务规模...

你还不懂java的日志系统吗 ?(java的日志类)

一、背景在java的开发中,使用最多也绕不过去的一个话题就是日志,在程序中除了业务代码外,使用最多的就是打印日志。经常听到的这样一句话就是“打个日志调试下”,没错在日常的开发、调试过程中打印日志是常干...

谈谈枚举的新用法--java(java枚举的作用与好处)

问题的由来前段时间改游戏buff功能,干了一件愚蠢的事情,那就是把枚举和运算集合在一起,然后运行一段时间后buff就出现各种问题,我当时懵逼了!事情是这样的,做过游戏的都知道,buff,需要分类型,且...

你还不懂java的日志系统吗(javaw 日志)

一、背景在java的开发中,使用最多也绕不过去的一个话题就是日志,在程序中除了业务代码外,使用最多的就是打印日志。经常听到的这样一句话就是“打个日志调试下”,没错在日常的开发、调试过程中打印日志是常干...

Java 8之后的那些新特性(三):Java System Logger

去年12月份log4j日志框架的一个漏洞,给Java整个行业造成了非常大的影响。这个事情也顺带把log4j这个日志框架推到了争议的最前线。在Java领域,log4j可能相对比较流行。而在log4j之外...

Java开发中的日志管理:让程序“开口说话”

Java开发中的日志管理:让程序“开口说话”日志是程序员的朋友,也是程序的“嘴巴”。它能让程序在运行过程中“开口说话”,告诉我们它的状态、行为以及遇到的问题。在Java开发中,良好的日志管理不仅能帮助...

吊打面试官(十二)--Java语言中ArrayList类一文全掌握

导读...

OS X 效率启动器 Alfred 详解与使用技巧

问:为什么要在Mac上使用效率启动器类应用?答:在非特殊专业用户的环境下,(每天)用户一般可以在系统中进行上百次操作,可以是点击,也可以是拖拽,但这些只是过程,而我们的真正目的是想获得结果,也就是...

Java中 高级的异常处理(java中异常处理的两种方式)

介绍异常处理是软件开发的一个关键方面,尤其是在Java中,这种语言以其稳健性和平台独立性而闻名。正确的异常处理不仅可以防止应用程序崩溃,还有助于调试并向用户提供有意义的反馈。...

【性能调优】全方位教你定位慢SQL,方法介绍下!

1.使用数据库自带工具...

全面了解mysql锁机制(InnoDB)与问题排查

MySQL/InnoDB的加锁,一直是一个常见的话题。例如,数据库如果有高并发请求,如何保证数据完整性?产生死锁问题如何排查并解决?下面是不同锁等级的区别表级锁:开销小,加锁快;不会出现死锁;锁定粒度...

看懂这篇文章,你就懂了数据库死锁产生的场景和解决方法

一、什么是死锁加锁(Locking)是数据库在并发访问时保证数据一致性和完整性的主要机制。任何事务都需要获得相应对象上的锁才能访问数据,读取数据的事务通常只需要获得读锁(共享锁),修改数据的事务需要获...