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 常见的并发问题处理方法总结的详细内容,更多关于Java 并发问题的资料请关注我们其它相关文章!

(0)

相关推荐

  • Java并发的CAS原理与ABA问题的讲解

    CAS原理 在计算机科学中,比较和交换(Compare And Swap)是用于实现多线程同步的原子指令. 它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值. 这是作为单个原子操作完成的. 原子性保证新值基于最新信息计算; 如果该值在同一时间被另一个线程更新,则写入将失败. 操作结果必须说明是否进行替换; 这可以通过一个简单的布尔响应(这个变体通常称为比较和设置),或通过返回从内存位置读取的值来完成(摘自维基本科) CAS流程 以AtomicIntege

  • Java HashMap源码及并发环境常见问题解决

    HashMap源码简单分析: 1 一切需要从HashMap属性字段说起: /** The default initial capacity - MUST be a power of two. 初始容量 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * The maximum capacity, used if a higher value is implicitly specified * by eit

  • 详解java解决分布式环境中高并发环境下数据插入重复问题

    java 解决分布式环境中 高并发环境下数据插入重复问题 前言 原因:服务器同时接受到的重复请求 现象:数据重复插入 / 修改操作 解决方案 : 分布式锁 对请求报文生成 摘要信息 + redis 实现分布式锁 工具类 分布式锁的应用 package com.nursling.web.filter.context; import com.nursling.nosql.redis.RedisUtil; import com.nursling.sign.SignType; import com.nu

  • java并发访问重复请求过滤问题

    问题描述 前段时间遇到个问题,自己内部系统调用出现重复请求导致数据混乱. 发生条件:接受到一个请求,该请求没有执行完成又接受到相同请求,导致数据错误(如果是前一个请求执行完成,马上又接受相同请求不会有问题) 问题分析:是由于数据库的脏读导致 问题解决思路 1.加一把大大的锁 (是最简单的实现方式,但是性能堪忧,而且会阻塞请求) 2.实现请求拦截 (可以共用,但是怎么去实现却是一个问题,怎么用一个优雅的方式实现,并且方便复用) 3.修改实现 (会对原有代码做改动,存在风险,最主要的是不能共用) 最

  • java并发问题概述

    1什么是并发问题. 多个进程或线程同时(或着说在同一段时间内)访问同一资源会产生并发问题. 银行两操作员同时操作同一账户就是典型的例子.比如A.B操作员同时读取一余额为1000元的账户,A操作员为该账户增加100元,B操作员同时为该账户减去50元,A先提交,B后提交.最后实际账户余额为1000-50=950元,但本该为1000+100-50=1050.这就是典型的并发问题.如何解决?可以用锁. 2java中synchronized的用法 用法1 public class Test{ public

  • Java并发编程this逃逸问题总结

    this逃逸是指在构造函数返回之前其他线程就持有该对象的引用. 调用尚未构造完全的对象的方法可能引发令人疑惑的错误, 因此应该避免this逃逸的发生. this逃逸经常发生在构造函数中启动线程或注册监听器时, 如: public class ThisEscape { public ThisEscape() { new Thread(new EscapeRunnable()).start(); // ... } private class EscapeRunnable implements Run

  • Java并发问题之乐观锁与悲观锁

    首先介绍一些乐观锁与悲观锁: 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁.传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁.再比如Java里面的同步原语synchronized关键字的实现也是悲观锁. 乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版

  • JAVA如何解决并发问题

    并发问题的根源在哪 首先,我们要知道并发要解决的是什么问题?并发要解决的是单进程情况下硬件资源无法充分利用的问题.而造成这一问题的主要原因是CPU-内存-磁盘三者之间速度差异实在太大.如果将CPU的速度比作火箭的速度,那么内存的速度就像火车,而最惨的磁盘,基本上就相当于人双腿走路. 这样造成的一个问题,就是CPU快速执行完它的任务的时候,很长时间都会在等待磁盘或是内存的读写. 计算机的发展有一部分就是如何重复利用资源,解决硬件资源之间效率的不平衡,而后就有了多进程,多线程的发展.并且演化出了各种

  • Java 常见的并发问题处理方法总结

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

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

    问题复现 1. "设备Aの奇怪分身" 时间回到很久很久以前的一个深夜,那时我开发的多媒体广告播放控制系统刚刚投产上线,公司开出的第一家线下生鲜店里,几十个大大小小的多媒体硬件设备正常联网后,正由我一台一台的注册及接入到已经上线的多媒体广告播控系统中. 注册过程简述如下: 每一个设备注册到系统中后,相应的在数据库设备表中都会新增一条记录,来存储这个设备的各项信息. 本来一切都有条不紊的进行着,直到设备A的注册打破了这默契的宁静-- 设备A注册完成后,我突然发现,数据库设备表中,新增了两条

  • java常见log日志的使用方法解析

    目录 前言 1. Java.util.Logger 2. org.apache.logging.log4j 3. org.slf4j.Logger 前言 log日志可以debug错误或者在关键位置输出想要的结果 java日志使用一般有原生logger.log4j.Slf4j等 一般的日志级别都有如下(不同日志不一样的方法参数,注意甄别) 参数 描述 OFF.ON 不输出或者输出所有级别信息,通常使用在setLevel方法中 FATAL 致命错误 ERROR 错误error WARN 告警信息 I

  • Java 处理高并发负载类优化方法案例详解

    java处理高并发高负载类网站中数据库的设计方法(java教程,java处理大量数据,java高负载数据) 一:高并发高负载类网站关注点之数据库 没错,首先是数据库,这是大多数应用所面临的首个SPOF.尤其是Web2.0的应用,数据库的响应是首先要解决的. 一般来说MySQL是最常用的,可能最初是一个mysql主机,当数据增加到100万以上,那么,MySQL的效能急剧下降.常用的优化措施是M-S(主-从)方式进行同步复制,将查询和操作和分别在不同的服务器上进行操作.我推荐的是M-M-Slaves

  • 浅谈Java中几种常见的比较器的实现方法

    在Java中经常会涉及到对象数组的排序问题,那么就涉及到对象之间的比较问题. 通常对象之间的比较可以从两个方面去看: 第一个方面:对象的地址是否一样,也就是是否引用自同一个对象.这种方式可以直接使用"=="来完成. 第二个方面:以对象的某一个属性的角度去比较. 从最新的JDK8而言,有三种实现对象比较的方法: 一.覆写Object类的equals()方法: 二.继承Comparable接口,并实现compareTo()方法: 三.定义一个单独的对象比较器,继承自Comparator接口

  • java中常见的死锁以及解决方法代码

    在java中我们常常使用加锁机制来确保线程安全,但是如果过度使用加锁,则可能导致锁顺序死锁.同样,我们使用线程池和信号量来限制对资源的使用,但是这些被限制的行为可能会导致资源死锁.java应用程序无法从死锁中恢复过来,因此设计时一定要排序那些可能导致死锁出现的条件. 1.一个最简单的死锁案例 当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被阻塞.在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去.这种就是最简答的死锁形式(

  • Java核心教程之常见时间日期的处理方法

    Java日期处理类Date详解 时间的基础知识 时区:整个地球分为二十四时区,每个时区都有自己的本地时间. 为了统一起见,使用一个统一的时间,称为全球标准时间(UTC,Universal Time Coordinated). TC与格林尼治平均时(GMT,Greenwich Mean Time,也翻译成:格林威治标准时间)差不多一样 CST(北京时间),北京时间,China standard Time,中国标准时间.在时区划分上,属东八区,比协调世界时早8小时,记为UTC+8. 时间戳:自197

  • Java中常见的并发控制手段浅析

    目录 前言 1.1 同步代码块 1.2 CAS自旋方式 1.3 锁 1.4 阻塞队列 1.5 信号量Semaphore 1.6 计数器CountDownLatch 1.7 栅栏 CyclicBarrier 1.8 guava令牌桶 1.9 滑动窗口TimeWindow 1.10 小结 前言 单实例的并发控制,主要是针对JVM内,我们常规的手段即可满足需求,常见的手段大概有下面这些 同步代码块 CAS自旋 锁 阻塞队列,令牌桶等 1.1 同步代码块 通过同步代码块,来确保同一时刻只会有一个线程执行

  • Java常见的3种文件上传方法和速度对比

    在java里面文件上传的方式很多,最简单的依然是FileInputStream.FileOutputStream了,在这里我列举3种常见的文件上传方法代码,并比较他们的上传速度(由于代码是在本地测试,所以忽略网速的影响) 还是老规矩,大神请绕一下,里屋说话. 首先呢,使用springMVC原生上传文件方法,需要一些简单的配置,不多说,上图. 1.采用spring提供的上传文件的方法 @RequestMapping("springUpload") public String spring

  • 基于Java子线程中的异常处理方法(通用)

    在普通的单线程程序中,捕获异常只需要通过try ... catch ... finally ...代码块就可以了.那么,在并发情况下,比如在父线程中启动了子线程,如何在父线程中捕获来自子线程的异常,从而进行相应的处理呢? 常见错误 也许有人会觉得,很简单嘛,直接在父线程启动子线程的地方try ... catch一把就可以了,其实这是不对的. 原因分析 让我们回忆一下Runnable接口的run方法的完整签名,因为没有标识throws语句,所以方法是不会抛出checked异常的.至于Runtime

随机推荐