zero's Blog

持续迭代

转载自:https://juejin.cn/post/7311167273650454580

背景

我们的主要业务是台湾省的一个小商城,这次出问题的是我们仓库系统。在仓库系统中有这么一段逻辑:

员工可以领取新建的订单,然后去执行拣货发货的操作,领取的时候,发货单的状态会从新建变为待拣货,也就是说找新建状态的发货单,领取然后变为待拣货然后去拣货

bug内容

突然有一天,仓库的同事发消息说有两位员工领取了同一个发货单。拣货的时候报错该发货单已拣货。 这可就奇了怪了,为了防止并发,且这个仓库是单节点部署的,记得是加了锁的

模拟

这时候需要定位问题,先模拟一下我们的场景。

1
2
3
4
5
6
7
8
9
10
@GetMapping(path = "test")
@ResponseBody
public void test(Pageable request){
for (int i = 0; i < 100; i++) {
//新建线程处理
new Thread(() -> {
userInfoService.testDemo();
}).start();
}
}

肯定是并发导致的,这里模拟一下高并发的情况

1
2
3
4
5
6
@Transactional(rollbackOn = Exception.class)
public synchronized void testDemo() {
UserInfo byUserId = userRepository.findByUserId(1);
byUserId.setAge(byUserId.getAge() + 1);
userRepository.save(byUserId);
}

可以看到业务逻辑里有个锁,并且有事务。

数据大概长这样,我拿之前我写的demo的表来处理,修改这个age 100次。

image.png

执行,不用看表了,看log就知道有问题的:

image.png

好家伙,这并发了啊,这锁了个寂寞啊。

好吧,查资料,很容易就查到了:

这种情况下,锁可能会失效。因为synchronized锁的是这个方法,而@Transactional是Spring的AOP在开启时自动锁定的。在进入这个方法前,AOP会先开启事务,然后进入方法,此时会加锁。当方法结束后,锁被释放,然后才会提交事务。如果在锁释放和提交事务之间有其他线程请求并再次加锁,这可能导致程序不安全。

所以,这应该就是问题所在了吧。

先改一版试试:

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping(path = "test")
@ResponseBody
public void test(Pageable request) {
for (int i = 0; i < 100; i++) {
//新建线程处理
new Thread(() -> {
synchronized (UserController.class) {
userInfoService.testDemo();
}
}).start();
}
}

将锁放到整个事务的外层,这样事务提交之后才会释放锁。

1
2
3
4
5
6
7
8
@Transactional(rollbackOn = Exception.class)
public void testDemo() {
UserInfo byUserId = userRepository.findByUserId(1);
log.info("当前线程:{},当前年龄:{}",Thread.currentThread().getName(),byUserId.getAge());
byUserId.setAge(byUserId.getAge() + 1);
userRepository.save(byUserId);
log.info("当前线程:{},当前年龄:{}",Thread.currentThread().getName(),byUserId.getAge());
}

看log,也是没有问题的

image.png

数据也没有问题

image.png

欸欸欸,好了。搞定,提代码,打包,发包,一气呵成。解决bug就是这么迅速。

又发生了

过了几天,仓库又反馈了,这bug又发生了,啊啊啊?奇了怪了。还没锁住?

在研究了好久,觉得这锁没问题啊,是哪里出了问题呢?各种权衡之下,先加了个分布式锁,以求解决问题。然而不出所料,过了两天又又又发生了。

又发生了,那就不是锁的问题了,那是什么的问题呢?这段业务很简单,拿到发货单,然后修改状态,然后存进去。就这,也没啥bug可以发生啊。

再从数据看,发现一个奇怪的地方,所有重复领取的发货单,都是在整点领取,然后发生bug的。这绝不是偶然,我在整点干啥了呢?

想起来了,我前一阵加了个功能,使用定时任务批量请求货代的打印面单接口然后将面单的url存到了发货单表里,这样,发货的时候就不用一个个请求这个接口了。这个任务就是整点跑的。。。。。

哎呦呦,我想通了,这样并发了啊,修改的同一张表,而且修改url的只会修改新建状态的url,

  1. 若是修改url的先获取发货单里所有新建状态的发货单
  2. 然后员工获取发货单数据,这时候因为修改url的线程需要调用api会稍微慢点
  3. 员工会把发货单修改为待拣货,然后保存入库
  4. 修改url的线程执行完了,由于我是执行的jpa的save方法,他会把自己读取到新建状态的数据,修改个url再保存回表,这时候,表里的数据又变成新建状态了。这样之后的员工又能取到这条发货单数据,然后这样不就重了。

找到原因了解决就很简单了,修改url的时候只修改这一个字段,其他的不修改就好了。

至此,bug解决掉了,修改数据库数据的时候save方法还是少用啊。一波三折啊,之前那种写法也是有问题的,幸亏一起改掉了,不然之后肯定也会继续发生。

[toc]

1. 解决什么问题

让我们先从事务说起,“什么是事务?我们为什么需要事务?”。事务是一组无法被分割的操作,要么所有操作全部成功,要么全部失败。我们在开发中需要通过事务将一些操作组成一个单元,来保证程序逻辑上的正确性,例如全部插入成功,或者回滚,一条都不插入。作为程序员的我们,对于事务管理,所需要做的便是进行事务的界定,即通过类似begin transaction和end transaction的操作来界定事务的开始和结束。

下面是一个基本的JDBC事务管理代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 开启数据库连接
Connection con = openConnection();
try {
// 关闭自动提交
con.setAutoCommit(false);
// 业务处理
// ...
// 提交事务
con.commit();
} catch (SQLException | MyException e) {
// 捕获异常,回滚事务
try {
con.rollback();
} catch (SQLException ex) {
ex.printStackTrace();
}
} finally {
// 关闭连接
try {
con.setAutoCommit(true);
con.close();
} catch (SQLException e) {
e.printStackTrace();
}
}

直接使用JDBC进行事务管理的代码直观上来看,存在两个问题:

  1. 业务处理代码与事务管理代码混杂;
  2. 大量的异常处理代码(在catch中还要try-catch)。而如果我们需要更换其他数据访问技术,例如Hibernate、MyBatis、JPA等,虽然事务管理的操作都类似,但API却不同,则需使用相应的API来改写。这也会引来第三个问题:
  3. 繁杂的事务管理API。

上文列出了三个待解决的问题,下面我们看Spring事务是如何解决。

Read more »

Java提供了一个基于FIFO队列—AbstractQueuedSynchronizer,可以用于构建锁或者其他相关同步装置的基础框架。该同步器(AQS)利用了一个int来表示状态,期望它能够成为实现大部分同步需求的基础。子类通过继承AQS并需要实现它的方法来管理其状态,管理的方式就是通过类似acquire和 release的方式来操纵状态。然而多线程环境中对状态的操纵必须确保原子性,因此子类对于状态的把握,需要使用这个同步器提供的以下三个方法对状态进行操作:

1
2
3
protected final int getState() {
return state;
}
1
2
3
protected final void setState(int newState) {
state = newState;
}
1
2
3
4
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

AQS的功能可以分为两类:独占功能和共享功能,它的所有子类中,要么实现并使用了它独占功能的API,要么使用了共享锁的功能,而不会同时使用两套 API,即便是它最有名的子类ReentrantReadWriteLock,也是通过两个内部类:读锁和写锁,分别实现的两套API来实现的,为什么这 么做,后面我们再分析,到目前为止,我们只需要明白AQS在功能上有独占控制和共享控制两种功能即可。

Read more »

Atomic包介绍

在Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新数组,原子更新引用和原子更新字段。Atomic包里的类基本都是使用Unsafe实现的包装类。

原子更新基本类型类

用于通过原子的方式更新基本类型,Atomic包提供了以下三个类:

AtomicBoolean:原子更新布尔类型。

AtomicInteger:原子更新整型。

AtomicLong:原子更新长整型。

原子更新数组类

通过原子的方式更新数组里的某个元素,Atomic包提供了以下三个类:

· AtomicIntegerArray:原子更新整型数组里的元素。

· AtomicLongArray:原子更新长整型数组里的元素。

· AtomicReferenceArray:原子更新引用类型数组里的元素。

Read more »

AQS,AbstractQueuedSynchronizer,即队列同步器。它是构建锁或者其他同步组件的基础框架,如ReentrantLock、ReentrantReadWriteLock、Semaphore等,它是JUC并发包中的核心基础组件。

AQS的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。AQS使用一个int类型的成员变量state来代表共享资源和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。当state>0时表示已经获取了锁,当state = 0时表示释放了锁。它提供了三个方法(getState()、setState(int newState)、compareAndSetState(int expect,int update))来对state进行操作,当然AQS可以确保对state的操作是安全的。

AQS通过内置的FIFO同步队列来完成资源获取线程的排队工作,如果当前线程获取同步状态失败(锁)时,AQS则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。AbstractQueuedSynchronizer类的数据结构如下:

Read more »

从JDK 5 开始,JMM就使用happens-before的概念来阐述多线程之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。下面我们就一个简单的例子稍微了解下happens-before:

1
2
i = 1;    //线程A执行  
j = i ; //线程B执行

j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后j = 1 一定成立,如果他们不存在happens-before原则,那么j = 1 不一定成立。这就是happens-before原则的威力。

Read more »

锁消除

  锁消除是JIT编译器对内部锁的具体实现所做的一种优化。
  在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问。如果同步块所使用的锁对象只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候并不生成synchronized所表示的锁的申请与释放对应的机器码,而仅生成原临界区代码对应的机器码,即消除了锁的使用。这种编译器优化就被称为锁消除,它使得特定情况下我们可以完全消除锁的开销。

  例如,StringBuffer虽然是线程安全的,但是在实际使用中往往不在多个线程间共享这些类的实例。而这些类在实现线程安全的时候往往借助于内部锁。因此,这些类是锁消除优化的常见目标。
  锁消除优化还可能需要以JIT编译器的内联优化为前提。而一个方法是否会被JIT编译器内联取决于该方法的热度以及该方法对应的字节码的尺寸(Bytecode Size)。因此,锁消除优化能否被实施还取决于被调用的同步方法(或者带同步块的方法)是否能够被内联。

Read more »

volatile类型的实例变量的修改,JDK提供了J.U.C下的atomic包下类来支持。

1
2
3
1、AtomicInteger/AtomicLong/AtomicBoolean/AtomicReference是关于对变量的原子更新,
2、AtomicIntegerArray/AtomicLongArray/AtomicReferenceArray是关于对数组的原子更新
3、AtomicIntegerFieldUpdater<T>/AtomicLongFieldUpdater<T>/AtomicReferenceFieldUpdater<T,V>是基于反射的原子更新字段的值。
Read more »

[toc]

synchronized

synchronized 关键字是一把经典的锁,也是我们平时用得最多的。在 JDK1.6 之前, syncronized 是一把重量级的锁,不过随着 JDK 的升级,也在对它进行不断的优化,如今它变得不那么重了,甚至在某些场景下,它的性能反而优于轻量级锁。在加了 syncronized 关键字的方法、代码块中,一次只允许一个线程进入特定代码段,从而避免多线程同时修改同一数据。

synchronized 锁有如下几个特点:

有锁升级过程

在 JDK1.5 (含)之前, synchronized 的底层实现是重量级的,所以之前一致称呼它为”重量级锁”,在 JDK1.5 之后,对 synchronized 进行了各种优化,它变得不那么重了,实现原理就是锁升级的过程。我们先聊聊 1.5 之后的 synchronized 实现原理是怎样的。说到 synchronized 加锁原理,就不得不先说 Java 对象在内存中的布局, Java 对象内存布局如下:

Read more »

[toc]

synchronized

在Java中,最基本的互斥同步手段就是synchronized关键字。synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

  1. 普通同步方法,锁是当前实例对象
  2. 静态同步方法,锁是当前类的class字节码对象
  3. 同步方法块,锁是括号里面的对象

synchronized的实现中,针对同步代码块是使用monitorenter和monitorexit指令实现的,而同步方法依靠的是方法修饰符上的ACC_SYNCHRONIZED实现(JVM底层实现)。

  • 同步代码块:monitorenter指令会插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;

  • 同步方法:synchronized方法则会被翻译成普通的方法调用和返回指令,如:invokevirtual、areturn指令,在JVM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。

Read more »
0%