2024年8月

LOTO
示波器统计曲线和故障分析pass/fail测试

虚拟示波器可以应用在工业自动化检测中,除了常规的检测波形和测量值参数以外,由多个行业客户定制和验证的统计曲线和故障分析(pass/fail)功能也为工业自动化检测带来极大的便利。

(一)故障分析(pass/fail)的基础:统计曲线功能

在信号检测的自动化测量中,大部分时间是关心某个测量值随时间变化的趋势,比如在开机检测后,波形的峰峰值是如何变化的。虚拟示波器的统计曲线功能,可以绘制出你关注的某些测量值的变化趋势曲线,如下图所示,示波器测试的信号最大值随着时间变化,从最低的0.49V逐渐变高,一直到4.73V,然后又降低到最低,接着缓缓升高并震荡:

通过这样的统计曲线,我们可以看到被监测的测量值的变化过程和趋势,从而为后面的故障分析做基础。

统计曲线功能的入口在“非标功能”中的“统计/故障判断”中,如下图所示:

(二)统计曲线功能可以观察哪些测量值:

理论上所有测量值,比如“最大值,最小值,峰峰值,有效值,平均值,频率,周期,占空比,正负脉宽,上升时间,下降时间”等等,都可以进行统计曲线的绘制,监测它们的变化趋势曲线。但是 虚拟示波器软件的标准版并没有开放所有这些测量值的统计曲线功能,根据型号不同和客户定制的情况不同,只开放了部分测量值的统计曲线功能。这些可以在统计曲线的配置页面看到。有些示波器型号支持多台级联的情况下,多台设备多通道的测量值的统计曲线绘制:

勾选上的测量值就可以在统计曲线绘图区看到对应的曲线,以不同的颜色区分。并且绘图区会在上下空白处用对应的颜色显示对应曲线的最大和最小数值,如下图所示:

(三)统计曲线的控制和现实

统计曲线只有在点击了“开始统计”按钮以后才会开始对测量值进行统计,这个按钮就会变成“停止统计”,点击了“停止统计”以后,就会停止统计曲线的绘制。

为了方便工业自动化测试,这个开始统计或停止统计按钮也可以不通过鼠标点击实现,可以由键盘快捷键或者示波器的IO口实现。

对应的键盘快捷键是“shift”+“z”, 对应的IO口是GPIO功能的IO2,也就是DE2扩展口的4脚。需要注意的是,如果需要IO2控制这个统计开始停止按钮,需要勾选对应的选项,如下图所示:

勾选了“IO2”开始后,IO2引脚的GPIO会被自动设置为输入,这个输入信号遵循3.3V TTL数字信号逻辑,由低电平跳变到高电平时,会被识别为点击了“开始统计”按钮,相反,这个输入信号由高电平跳变为低电平时,会被识别为点击了“停止统计”按钮。

“开始统计”被点击或者触发后,会清零之前的统计曲线波形和相关的数据,如果开启了故障pass/fail测试,也会清零故障信息。

(四)故障分析pass/fail测试

在上面的统计曲线的基础上,我们可以为测量值对应的每条统计曲线设定曲线的上下限,在上下限范围内的统计曲线变化被认为是正常的,也就是pass,一旦超过上下限的范围,则认为有故障发生,也就是fail。

故障分析的设定是在如下位置:

pass/fail测试的结果会在统计曲线绘图区的下方通过色块和文字表示出来,如下图所示:

具体是哪个或者哪几个测量值产生了fault的故障,我们也可以在下面的信息栏里看到,会显示“通道号:测量值”形式的故障信息。

为了方便客户在工业自动化的信号检测中,更方便的自动化处理故障分析,比如使用实体的报警灯,或者喇叭,或者和PLC联动实现某些动作,故障发生后,除了在 示波器的上位机软件上显示外,还可以使用IO口输出。我们可以在下图所示位置,选中IO3警报,就会自动将示波器的GPIO功能的IO3,也就是扩展口DE2的10脚设置为输出,IO3同样也是遵循3.3V TTL 数字逻辑。

默认的情况下,如果是PASS状态,那么IO3输出低电平,如果是fail状态,那么IO3将输出高电平。如果需要的是相反的逻辑,那么可以在故障的设置页面勾选“IO3警报逻辑反向”选项:

(五)状态清除

统计曲线的历史数据和波形,以及故障分析的结果等,都可以通过点击按钮“清除”进行清空。清空后波形和数据将清零,如果勾选了IO3警报的话,那么IO3的输出状态也会被清除。除了手动点击这个清除之外,“停止统计”后的点击“开始统计”时,也会对统计和故障信息自动清除,如果勾选了“IO2开始”,那么从停止到开始的IO状态切换,也会对统计和故障信息自动清除。

(六)设置记忆/保存和导入

以上的统计曲线的设置和故障分析的设置都是可以记忆和另存为配置文件的,配置文件可以手动导入回来。这样在工业自动化检测时会更加便利。关于这部分内容我们会在其他部分专门描述。

关于统计曲线和故障分析的使用,可以参考以下视频演示:

《 示波器 软件功能 演示 之 测量值统计曲线功能演示 以及 自动化检测应用实例》
https://www.bilibili.com/video/BV1RJ411C73h/

《示波器-统计曲线2-故障联动-自动化检测 自动化集成 信号监测判断 pass/fail测试》

https://www.bilibili.com/video/BV1h5411u7zP/?vd_source=c0bedc6c664e75d3c19935cbda8abe19

《示波器 统计曲线 故障检测 PASS/Fail 功能优化3 工业自动化 产线检测 自动化检测》

https://www.bilibili.com/video/BV1Hx4y1b73n/?vd_source=c0bedc6c664e75d3c19935cbda8abe19

《示波器_多台级联配置记忆+统计故障分析配置记忆功能(1)》

https://www.bilibili.com/video/BV1QcvXe1EG6/?vd_source=c0bedc6c664e75d3c19935cbda8abe19

《示波器_多台级联配置记忆+统计故障分析配置记忆功能(2)》

https://www.bilibili.com/video/BV1iDvQeWEYk/?vd_source=c0bedc6c664e75d3c19935cbda8abe19

《示波器更新: Pass/Fail测试_统计曲线功能(3) GPIO控制 自动化检测 工业检测》

https://www.bilibili.com/video/BV1pqvQeAEvk/?vd_source=c0bedc6c664e75d3c19935cbda8abe19

接手新项目一言难尽,别的不说单单就一个
@Transactional
注解用的一塌糊涂,五花八门的用法,很大部分还失效无法回滚。

有意识的在涉及事务相关方法上加
@Transactional
注解,是个好习惯。不过,很多同学只是下意识地添加这个注解,一旦功能正常运行,很少有人会深入验证异常情况下事务是否能正确回滚。@Transactional 注解虽然用起来简单,但这货总是能在一些你意想不到的情况下失效,防不胜防!

我把这些事务问题归结成了三类:
不必要

不生效

不回滚
,接下用一些demo演示下各自的场景。

不必要

1. 无需事务的业务

在没有事务操作的业务方法上使用 @Transactional 注解,比如:用在仅有查询或者一些 HTTP 请求的方法,虽然加上影响不大,但从编码规范的角度来看还是不够严谨,建议去掉。

@Transactional
public String testQuery() {
    standardBak2Service.getById(1L);
    return "testB";
}

2. 事务范围过大

有些同学为了省事直接将 @Transactional 注解加在了类上或者抽象类上,这样做导致的问题就是
类内的方法或抽象类的实现类中所有方法全部都被事务管理
。增加了不必要的性能开销或复杂性,建议按需使用,只在有事务逻辑的方法上加@Transactional。

@Transactional
public abstract class BaseService {
}

@Slf4j
@Service
public class TestMergeService extends BaseService{

    private final TestAService testAService;

    public String testMerge() {

        testAService.testA();

        return "ok";
    }
}

如果在类中的方法上添加 @Transactional 注解,它将覆盖类级别的事务配置。例如,类级别上配置了只读事务,方法级别上的 @Transactional 注解也会覆盖该配置,从而启用读写事务。

@Transactional(readOnly = true)
public class TestMergeService {

    private final TestBService testBService;

    private final TestAService testAService;

    @Transactional
    public String testMerge() {

        testAService.testA();

        testBService.testB();
        return "ok";
    }
}    

不生效

3. 方法权限问题

不要把 @Transactional注解加在 private 级别的方法上!

我们知道 @Transactional 注解依赖于Spring AOP切面来增强事务行为,这个 AOP 是通过代理来实现的,而 private 方法恰恰不能被代理的,所以 AOP 对 private 方法的增强是无效的,@Transactional也就不会生效。

@Transactional
private String testMerge() {

    testAService.testA();

    testBService.testB();

    return "ok";
}

那如果我在 testMerge() 方法内调用 private 的方法事务会生效吗?

答案:事务会生效

@Transactional
public String testMerge() throws Exception {

    ccc();
    
    return "ok";
}

private void ccc() {
    testAService.testA();

    testBService.testB();
}

4. 被用 final 、static 修饰方法

和上边的原因类似,被用
final

static
修饰的方法上加 @Transactional 也不会生效。

  • static 静态方法属于类本身的而非实例,因此代理机制是无法对静态方法进行代理或拦截的
  • final 修饰的方法不能被子类重写,事务相关的逻辑无法插入到 final 方法中,代理机制无法对 final 方法进行拦截或增强。

这些都是java基础概念了,使用时要注意。

@Transactional
public static void b() {
}

@Transactional
public final void b() {
}

5. 同类内部方法调用问题

注意了
,这种情况经常发生啊!

同类内部方法间的调用是 @Transactional 注解失效的重灾区,网上你总能看到方法内部调用另一个同类的方法时,
这种调用是不会经过代理的
,因此事务管理不会生效。但这说法比较片面,要分具体情况。

比如:testMerge() 方法开启事务,调用同类非事务的方法 a() 和 b() ,此时 b() 抛异常,根据事务的传播性 a()、b() 事务均生效。

@Transactional
public String testMerge() {

    a();

    b();

    return "ok";
}

public void a() {
    standardBakService.save(testAService.buildEntity());
}

public void b() {
    standardBak2Service.save(testBService.buildEntity2());
    throw new RuntimeException("b error");
}

如果 testMerge() 方法未开启事务,并且在同类中调用了非事务方法 a() 和事务方法 b(),当 b() 抛出异常时,a() 和 b() 的事务都不会生效。因为这种调用直接通过
this
对象进行,未经过代理,因此事务管理无法生效。这经常出问题的!

public String testMerge() {

    a();

    b();

    return "ok";
}

public void a() {
    standardBakService.save(testAService.buildEntity());
}

@Transactional
public void b() {
    standardBak2Service.save(testBService.buildEntity2());
    throw new RuntimeException("b error");
}

5.1 独立的 Service 类

要想 b() 方法的事务生效也容易,最简单的方法将它剥离放在独立的Service类注入使用,交给spring管理就行了。不过,这种方式会创建很多类。

@Slf4j
@Service
public class TestBService {

      @Transactional
      public void b() {
          standardBak2Service.save(testBService.buildEntity2());
          throw new RuntimeException("b error");
      }
}

5.2 自注入方式

或者通过自己注入自己的方式解决,尽管解决了问题,逻辑看起来很奇怪,它破坏了依赖注入的原则,虽然 spring 支持我们这样用,还是要注意下循环依赖的问题。

@Slf4j
@Service
public class TestMergeService {
      @Autowired
      private TestMergeService testMergeService;

      public String testMerge() {

          a();

          testMergeService.b();

          return "ok";
      }

      public void a() {
          standardBakService.save(testAService.buildEntity());
      }

      @Transactional
      public void b() {
          standardBak2Service.save(testBService.buildEntity2());
          throw new RuntimeException("b error");
      }
}

5.3 手动获取代理对象

b() 方法它不是没被代理嘛,那我们手动获取代理对象调用 b() 方法也可以。通过
AopContext.currentProxy()
方法返回当前的代理对象实例,这样调用代理的方法时,就会经过 AOP 的切面,@Transactional注解就会生效了。

@Slf4j
@Service
public class TestMergeService {

      public String testMerge() {

          a();

         ((TestMergeService) AopContext.currentProxy()).b();

          return "ok";
      }

      public void a() {
          standardBakService.save(testAService.buildEntity());
      }

      @Transactional
      public void b() {
          standardBak2Service.save(testBService.buildEntity2());
          throw new RuntimeException("b error");
      }
}

6. Bean 未被 spring 管理

上边我们知道 @Transactional 注解通过 AOP 来管理事务,而 AOP 依赖于代理机制。因此,
Bean 必须由Spring管理实例!
要确保为类加上如
@Controller

@Service

@Component
注解,让其被Spring所管理,这很容易忽视。

@Service
public class TestBService {

    @Transactional
    public String testB() {
        standardBak2Service.save(entity2);
        return "testB";
    }
}

7. 异步线程调用

如果我们在 testMerge() 方法中使用异步线程执行事务操作,通常也是无法成功回滚的,来个具体的例子。

testMerge() 方法在事务中调用了 testA(),testA() 方法中开启了事务。接着,在 testMerge() 方法中,我们通过一个新线程调用了 testB(),testB() 中也开启了事务,并且在 testB() 中抛出了异常。

此时的回滚情况是怎样的呢?

@Transactional
public String testMerge() {

    testAService.testA();

    new Thread(() -> {
        try {
            testBService.testB();
        } catch (Exception e) {
//                e.printStackTrace();
            throw new RuntimeException();
        }
    }).start();

    return "ok";
}

@Transactional
public String testB() {
    DeepzeroStandardBak2 entity2 = buildEntity2();

    dataImportJob2Service.save(entity2);

    throw new RuntimeException("test2");
}

@Transactional
public String testA() {
    DeepzeroStandardBak entity = buildEntity();
    standardBakService.save(entity);
    return "ok";
}

答案是:testA() 和 testB() 中的事务都不会回滚。

testA() 无法回滚是因为没有捕获到新线程中 testB()抛出的异常;testB()方法无法回滚,是因为事务管理器只对当前线程中的事务有效,因此在新线程中执行的事务不会回滚。

由于在多线程环境下,Spring 的事务管理器不会跨线程传播事务,事务的状态(如事务是否已开启)是存储在线程本地的
ThreadLocal
来存储和管理事务上下文信息。这意味着每个线程都有一个独立的事务上下文,事务信息在不同线程之间不会共享。

8. 不支持事务的引擎

不支持事务的数据库引擎不在此次
Review
范围内,只做了解就好。我们通常使用的关系型数据库,如 MySQL,默认使用支持事务的
InnoDB
引擎,而非事务的
MyISAM
引擎则使用较少。

以前开启启用 MyISAM 引擎是为了提高查询效率。不过,现在非关系型数据库如
Redis

MongoDB

Elasticsearch
等中间件提供了更高性价比的解决方案。

不回滚

9. 用错传播属性

@Transactional
注解有个关键的参数
propagation
,它控制着事务的传播行为,有时事务传播参数配置错误也会导致事务的不回滚。

propagation 支持 7 种事务传播特性:

  • REQUIRED

    默认的传播行为
    ,如果当前没有事务,则创建一个新事务;如果存在事务,则加入当前事务。
  • MANDATORY
    :支持当前事务,如果不存在则抛出异常
  • NEVER
    :非事务性执行,如果存在事务,则抛出异常
  • REQUIRES_NEW
    :无论当前是否存在事务,都会创建一个新事务,原有事务被挂起。
  • NESTED
    :嵌套事务,被调用方法在一个嵌套的事务中运行,这个事务依赖于当前的事务。
  • SUPPORTS
    :如果当前存在事务,则加入;如果没有,就以非事务方式执行。
  • NOT_SUPPORTED
    :以非事务方式执行,如果当前存在事务,将其挂起。

为了加深印象,我用案例来模拟下每种特性的使用场景。

REQUIRED

REQUIRED 是默认的事务传播行为。如果 testMerge() 方法开启了事务,那么其内部调用的 testA() 和 testB() 方法也将加入这个事务。如果 testMerge() 没有开启事务,而 testA() 和 testB() 方法上使用了 @Transactional 注解,这些方法将各自创建新的事务,只控制自身的回滚。

@Component
@RequiredArgsConstructor
@Slf4j
@Service
public class TestMergeService {

    private final TestBService testBService;

    private final TestAService testAService;

    @Transactional
    public String testMerge() {

        testAService.testA();

        testBService.testB();

        return "ok";
    }
}

@Transactional
public String testA() {
    log.info("testA");
    DeepzeroStandardBak entity = buildEntity();
    standardBakService.save(entity);
    return "ok";
}

@Transactional
public String testB() {
    log.info("testB");
    DeepzeroStandardBak2 entity2 = buildEntity2();
    standardBak2Service.save(entity2);
    throw new RuntimeException("testB");
}

MANDATORY

MANDATORY 传播特性简单来说就是只能被开启事务的上层方法调用,例如 testMerge() 方法未开启事务调用 testB() 方法,那么将抛出异常;testMerge() 开启事务调用 testB() 方法,则加入当前事务。

@Component
@RequiredArgsConstructor
@Slf4j
@Service
public class TestMergeService {

    private final TestBService testBService;

    private final TestAService testAService;

    public String testMerge() {

        testAService.testA();

        testBService.testB();

        return "ok";
    }
}

@Transactional
public String testA() {
    log.info("testA");
    DeepzeroStandardBak entity = buildEntity();
    standardBakService.save(entity);
    return "ok";
}

@Transactional(propagation = Propagation.MANDATORY)
public String testB() {
    log.info("testB");
    DeepzeroStandardBak2 entity2 = buildEntity2();
    standardBak2Service.save(entity2);
    throw new RuntimeException("testB");
}

抛出的异常信息

org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'

NEVER

NEVER 传播特性是强制你的方法只能以非事务方式运行,如果方法存在事务操作会抛出异常,我实在是没想到有什么使用场景。

@Transactional(propagation = Propagation.NEVER)
public String testB() {
    log.info("testB");
    DeepzeroStandardBak2 entity2 = buildEntity2();
    standardBak2Service.save(entity2);
//        throw new RuntimeException("testB");
    return "ok";
}

抛出的异常信息

org.springframework.transaction.IllegalTransactionStateException: Existing transaction found for transaction marked with propagation 'never'

REQUIRES_NEW

我们在使用 Propagation.REQUIRES_NEW 传播特性时,不论当前事务的状态如何,调用该方法都会创建一个新的事务。

例如,testMerge() 方法开始一个事务,调用 testB() 方法时,它会暂停 testMerge() 的事务,并启动一个新的事务。如果 testB() 方法内部发生异常,新事务会回滚,但原先挂起的事务不会受影响。这意味着,挂起的事务不会因为新事务的回滚而受到影响,也不会因为新事务的失败而回滚。


@Transactional
public String testMerge() {

    testAService.testA();

    testBService.testB();

    return "ok";
}

@Transactional
public String testA() {
    log.info("testA");
    DeepzeroStandardBak entity = buildEntity();
    standardBakService.save(entity);
    return "ok";
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public String testB() {
    log.info("testB");
    DeepzeroStandardBak2 entity2 = buildEntity2();
    standardBak2Service.save(entity2);
    throw new RuntimeException("testB");
}

NESTED

方法的传播行为设置为 NESTED,其内部方法会开启一个新的嵌套事务(子事务)。在没有外部事务的情况下
NESTED

REQUIRED
效果相同;存在外部事务的情况下,一旦外部事务回滚,它会创建一个嵌套事务(子事务)。

也就是说外部事务回滚时,子事务会跟着回滚;但子事务的回滚不会对外部事务和其他同级事务造成影响。

@Component
@RequiredArgsConstructor
@Slf4j
@Service
public class TestMergeService {

    private final TestBService testBService;

    private final TestAService testAService;

    @Transactional
    public String testMerge() {

        testAService.testA();

        testBService.testB();

        throw new RuntimeException("testMerge");
        return "ok";
    }
}

@Transactional
public String testA() {
    log.info("testA");
    DeepzeroStandardBak entity = buildEntity();
    standardBakService.save(entity);
    return "ok";
}

@Transactional(propagation = Propagation.NESTED)
public String testB() {
    log.info("testB");
    DeepzeroStandardBak2 entity2 = buildEntity2();
    standardBak2Service.save(entity2);
    throw new RuntimeException("testB");
}

NOT_SUPPORTED

NOT_SUPPORTED
事务传播特性表示该方法必须以非事务方式运行。当方法 testMerge() 开启事务并调用事务方法 testA() 和 testB() 时,如果 testA() 和 testB() 的事务传播特性为 NOT_SUPPORTED,那么 testB() 将以非事务方式运行,并挂起当前的事务。

默认传播特性的情况下 testB() 异常事务加入会导致 testA() 回滚,而挂起的意思是说,testB() 其内部一旦抛出异常,不会影响 testMerge() 中其他 testA() 方法的回滚。

@Component
@RequiredArgsConstructor
@Slf4j
@Service
public class TestMergeService {

    private final TestBService testBService;

    private final TestAService testAService;

    @Transactional
    public String testMerge() {

        testAService.testA();

        testBService.testB();

        return "ok";
    }
}

@Transactional
public String testA() {
    log.info("testA");
    DeepzeroStandardBak entity = buildEntity();
    standardBakService.save(entity);
    return "ok";
}

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public String testB() {
    log.info("testB");
    DeepzeroStandardBak2 entity2 = buildEntity2();
    standardBak2Service.save(entity2);
    throw new RuntimeException("testB");
}

SUPPORTS

如果当前方法的事务传播特性是
SUPPORTS
,那么只有在调用该方法的上层方法开启了事务的情况下,该方法的事务才会有效。如果上层方法没有开启事务,那么该方法的事务特性将无效。

例如,如果入口方法 testMerge() 没有开启事务,而 testMerge() 调用的方法 testA() 和 testB() 的事务传播特性为 SUPPORTS,那么由于 testMerge() 没有事务,testA() 和 testB() 将以非事务方式执行。即使在这些方法上加上
@Transactional
注解,也不会回滚异常。

@Component
@RequiredArgsConstructor
@Slf4j
@Service
public class TestMergeService {

    private final TestBService testBService;

    private final TestAService testAService;

    public String testMerge() {

        testAService.testA();

        testBService.testB();

        return "ok";
    }
}

@Transactional(propagation = Propagation.SUPPORTS)
public String testA() {
    log.info("testA");
    DeepzeroStandardBak entity = buildEntity();
    standardBakService.save(entity);
    return "ok";
}

@Transactional(propagation = Propagation.SUPPORTS)
public String testB() {
    log.info("testB");
    DeepzeroStandardBak2 entity2 = buildEntity2();
    standardBak2Service.save(entity2);
    throw new RuntimeException("testB");
}

10. 自己吞了异常

在整个 review 的过程中我发现导致事务不回滚的场景,多数是开发同学在业务代码中手动 try...catch 捕获了异常,然后又没抛出异常....

比如:testMerge() 方法开启了事务,并调用了非事务方法 testA() 和 testB(),同时在 testMerge() 中捕获了异常。如果 testB() 中发生了异常并抛出,但 testMerge() 捕获了这个异常而没有继续抛出,Spring 事务将无法捕获到异常,从而无法进行回滚。

@RequiredArgsConstructor
@Slf4j
@Service
public class TestMergeService {

    private final TestBService testBService;

    private final TestAService testAService;
    @Transactional
    public String testMerge() {

        try {
            testAService.testA();

            testBService.testB();

        } catch (Exception e) {
            log.error("testMerge error:{}", e);
        }
        return "ok";
    }
}

@Service
public class TestAService {

    public String testA() {
        standardBakService.save(entity);
        return "ok";
    }
}

@Service
public class TestBService {

    public String testB() {
        standardBakService.save(entity2);
        
        throw new RuntimeException("test2");
    }
}

为了确保 Spring 事务能够正常回滚,需要我们在 catch 块中主动重新抛出它能够处理的 RuntimeException 或者 Error 类型的异常。

@Transactional
public String testMerge() {

    try {
        testAService.testA();

        testBService.testB();

    } catch (Exception e) {
        log.error("testMerge error:{}", e);
        throw new RuntimeException(e);
    }
    return "ok";
}

捕获异常并不意味着一定不会回滚
,这取决于具体情况。

例如,当 testB() 方法上也加上了 @Transactional 注解时,如果在该方法中发生异常,事务会捕获到这个异常。由于事务传播的特性,testB() 的事务会合并到上层方法的事务中。因此,即使在 testMerge() 中捕获了异常而未抛出,事务仍然可以成功回滚。

@Transactional
public String testB() {

    DeepzeroStandardBak2 entity2 = buildEntity2();

    dataImportJob2Service.save(entity2);

    throw new RuntimeException("test2");
    // return "ok";
}

但这有个提前,必须在 testMerge() 方法上添加 @Transactional 注解以启用事务。如果 testMerge() 方法没有开启事务,不论其内部是否使用 try 块,都只能部分回滚 testB(),而 testA() 将无法回滚。

11. 事务无法捕获的异常

Spring 的事务默认会回滚
RuntimeException
及其子类,以及
Error
类型的异常。

如果抛出的是其他类型的异常,例如
checked exceptions
(检查型异常),即继承自 Exception 但不继承自 RuntimeException 的异常,比如
SQLException

DuplicateKeyException
,事务将不会回滚。

所以,我们在主动抛出异常时,要确保该异常是事务能够捕获的类型。

@Transactional
public String testMerge() throws Exception {
    try {
        testAService.testA();

        testBService.testB();
    } catch (Exception e) {
        log.error("testMerge error:{}", e);
//            throw new RuntimeException(e);
        throw new Exception(e);
    }
    return "ok";
}

如果你非要抛出默认情况下不会导致事务回滚的异常,务必要在
@Transactional
注解的
rollbackFor
参数中明确指定该异常,这样才能进行回滚。

@Transactional(rollbackFor = Exception.class)
public String testMerge() throws Exception {
    try {
        testAService.testA();

        testBService.testB();
    } catch (Exception e) {
        log.error("testMerge error:{}", e);
//            throw new RuntimeException(e);
        throw new Exception(e);
    }
    return "ok";
}

问问你身边的同学,哪些异常属于运行时异常,哪些属于检查型异常,十有八九他们可能无法给出准确的回答!

所以减少出现 bug 的风险,我建议使用 @Transactional 注解时,将 rollbackFor 参数设置为
Exception

Throwable
,这样可以扩大事务回滚的范围。

12. 自定义异常范围问题

针对不同业务定制异常类型是比较常见的做法,@Transactional 注解的 rollbackFor 参数支持自定义的异常,但我们往往习惯于将这些自定义异常继承自 RuntimeException。

那么这就出现和上边同样的问题,事务的范围不足,许多异常类型仍然无法触发事务回滚。

@Transactional(rollbackFor = CustomException.class)
public String testMerge() throws Exception {
    try {
        testAService.testA();

        testBService.testB();
    } catch (Exception e) {
        log.error("testMerge error:{}", e);
//            throw new RuntimeException(e);
        throw new Exception(e);
    }
    return "ok";
}

想要解决这个问题,可以在 catch 中主动抛出我们自定义的异常。

@Transactional(rollbackFor = CustomException.class)
public String testMerge() throws Exception {
    try {
        testAService.testA();

        testBService.testB();
    } catch (Exception e) {
        log.error("testMerge error:{}", e);
        throw new CustomException(e);
    }
    return "ok";
}

13. 嵌套事务问题

还有一种场景就是嵌套事务问题,比如,我们在 testMerge() 方法中调用了事务方法 testA() 和事务方法 testB(),此时不希望 testB() 抛出异常让整个 testMerge() 都跟着回滚;这就需要单独 try catch 处理 testB() 的异常,不让异常在向上抛。

@RequiredArgsConstructor
@Slf4j
@Service
public class TestMergeService {

    private final TestBService testBService;

    private final TestAService testAService;
    @Transactional
    public String testMerge() {
    
        testAService.testA();

        try {
            testBService.testB();
        } catch (Exception e) {
            log.error("testMerge error:{}", e);
        }
        return "ok";
    }
}

@Service
public class TestAService {

    @Transactional
    public String testA() {
        standardBakService.save(entity);
        return "ok";
    }
}

@Service
public class TestBService {

    @Transactional
    public String testB() {
        standardBakService.save(entity2);
        
        throw new RuntimeException("test2");
    }
}

总结

上面的关于 @Transactional 注解的使用注意事项是我在代码审查和搜集网络观点后整理出的。之前我也写过类似的文章,但当时内容不够全面。这次的补充更为详尽,涵盖了更多细节。开发工作只是整体工作量的一小部分,更多时间实际上花在了自测和验证上。希望这些案例对大家有所收获,少踩坑。

大家好,我是苏三,又跟大家见面了。

前言

最近有位小伙伴在我的技术群里,问了我一个问题:服务down机了,线程池中如何保证不丢失数据?

这个问题挺有意思的,今天通过这篇文章,拿出来跟大家一起探讨一下。

1 什么是线程池?

之前没有线程池的时候,我们在代码中,创建一个线程有两种方式:

  1. 继承Thread类
  2. 实现Runnable接口

虽说通过这两种方式创建一个线程,非常方便。

但也带来了下面的问题:

  1. 创建和销毁一个线程,都是比较耗时,频繁的创建和销毁线程,非常影响系统的性能。
  2. 无限制的创建线程,会导致内存不足。
  3. 有新任务过来时,必须要先创建好线程才能执行,不能直接复用线程。

为了解决上面的这些问题,Java中引入了:
线程池

它相当于一个存放线程的池子。

使用线程池带来了下面3个好处:

  1. 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  2. 提高响应速度。当任务到达时,可以直接使用已有空闲的线程,不需要的等到线程创建就能立即执行。
  3. 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性。而如果我们使用线程池,可以对线程进行统一的分配、管理和监控。

2 线程池原理

先看看线程池的构造器:

public ThreadPoolExecutor(
    int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler)
  • corePoolSize:核心线程数,线程池维护的最少线程数。
  • maximumPoolSize:最大线程数,线程池允许创建的最大线程数。
  • keepAliveTime:线程存活时间,当线程数超过核心线程数时,多余的空闲线程的存活时间。
  • unit:时间单位。
  • workQueue:任务队列,用于保存等待执行的任务。
  • threadFactory:线程工厂,用于创建新线程。
  • handler:拒绝策略,当任务无法执行时的处理策略。

线程池的核心流程图如下:

线程池的工作过程如下:

  1. 线程池初始化:根据corePoolSize初始化核心线程。
  2. 任务提交:当任务提交到线程池时,根据当前线程数判断:
  • 若当前线程数小于corePoolSize,创建新的线程执行任务。
  • 若当前线程数大于或等于corePoolSize,任务被加入workQueue队列。
  1. 任务处理:当有空闲线程时,从workQueue中取出任务执行。
  2. 线程扩展:若队列已满且当前线程数小于maximumPoolSize,创建新的线程处理任务。
  3. 线程回收:当线程空闲时间超过keepAliveTime,多余的线程会被回收,直到线程数不超过corePoolSize。
  4. 拒绝策略:若队列已满且当前线程数达到maximumPoolSize,则根据拒绝策略处理新任务。

说白了在线程池中,多余的任务会被放到workQueue任务队列中。

这个任务队列的数据保存在内存中。

这样就会出现一些问题。

接下来,看看线程池有哪些问题。

3 线程池有哪些问题?

在JDK中为了方便大家创建线程池,专门提供了Executors这个工具类。

3.1 队列过大

Executors.newFixedThreadPool,它可以创建固定线程数量的线程池,任务队列使用的是LinkedBlockingQueue,默认最大容量是Integer.MAX_VALUE。

public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
    return new ThreadPoolExecutor(nThreads, 
                               nThreads,
                                     0L, 
                  TimeUnit.MILLISECONDS,
     new LinkedBlockingQueue<Runnable>(),
                          threadFactory);
}

如果向newFixedThreadPool线程池中提交的任务太多,可能会导致LinkedBlockingQueue非常大,从而出现OOM问题。

3.2 线程太多

Executors.newCachedThreadPool,它可以创建可缓冲的线程池,最大线程数量是Integer.MAX_VALUE,任务队列使用的是SynchronousQueue。

public static ExecutorService newCachedThreadPool() {
  return new ThreadPoolExecutor(0, 
                Integer.MAX_VALUE,
                               60L, 
                  TimeUnit.SECONDS,
    new SynchronousQueue<Runnable>());
}

如果向newCachedThreadPool线程池中提交的任务太多,可能会导致创建大量的线程,也会出现OOM问题。

3.3 数据丢失

如果线程池在执行过程中,服务突然被重启了,可能会导致线程池中的数据丢失。

上面的OOM问题,我们在日常开发中,可以通过自定义线程池的方式解决。

比如创建这样的线程池:

new ThreadPoolExecutor(8, 
                       10,
                       30L, 
     TimeUnit.MILLISECONDS,
    new ArrayBlockingQueue<Runnable>(300),
            threadFactory);

自定义了一个最大线程数量和任务队列都在可控范围内线程池。

这样做基本上不会出现OOM问题。

但线程池的数据丢失问题,光靠自身的功能很难解决。

4 如何保证数据不丢失?

线程池中的数据,是保存到内存中的,一旦遇到服务器重启了,数据就会丢失。

之前的系统流程是这样的:

用户请求过来之后,先处理业务逻辑1,它是系统的核心功能。

然后再将任务提交到线程池,由它处理业务逻辑2,它是系统的非核心功能。

但如果线程池在处理的过程中,服务down机了,此时,业务逻辑2的数据就会丢失。

那么,如何保证数据不丢失呢?

答:需要
提前做持久化

我们优化的系统流程如下:

用户请求过来之后,先处理业务逻辑1,紧接着向DB中写入一条任务数据,状态是:待执行。

处理业务逻辑1和向DB写任务数据,可以在同一个事务中,方便出现异常时回滚。

然后有一个专门的定时任务,每个一段时间,按添加时间升序,分页查询状态是待执行的任务。

最早的任务,最先被查出来。

然后将查出的任务提交到线程池中,由它处理业务逻辑2。

处理成功之后,修改任务的待执行状态为:已执行。

需要注意的是:业务逻辑2的处理过程,要做幂等性设计,同一个请求允许被执行多次,其结果不会有影响。

如果此时,线程池在处理的过程中,服务down机了,业务逻辑2的数据会丢失。

但此时DB中保存了任务的数据,并且丢失那些任务的状态还是:待执行。

在下一次定时任务周期开始执行时,又会将那些任务数据重新查询出来,重新提交到线程池中。

业务逻辑2丢失的数据,又自动回来了。

如果要考虑失败的情况,还需要在任务表中增加一个
失败次数
字段。

在定时任务的线程池中执行业务逻辑2失败了,在下定时任务执行时可以自动重试。

但不可能无限制的一直重试下去。

当失败超过了一定的次数,可以将任务状态改成:失败。

这样后续可以人工处理。

最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。

AirPlay
协议是苹果开发、广泛应用于iPhone、iPad和Mac设备,可以通过WiFi将iPhone、iPad等iOS设备上的图片、音频、视频通过无线的方式传输到支持AirPlay 设备。即移动终端显示什么电视大屏就显示什么。随着AirPlay协议逐步普及,国内越来越多网络机顶盒,智能电视都集成了AirPlay协议。AirPlay的镜像效果是所有投屏方式中效果最佳的。

如有需要对接AirPlay,接收和发送都有开源代码可以参考:

接收端
SteeBono/airplayreceiver: Open source implementation of AirPlay 2 Mirroring / Audio protocol. (github.com)
,接收兼容场景会更多点,自研投屏协议需要考虑兼容外部原生投屏协议、提升用户体验。

发送端
openairplay/AirPlayer: AirPlayer is a .NET project for streaming photos, video and music to airplay devices. (github.com)

Miracast
协议是由Wi-Fi联盟于2012年所制定,以WiFi直连为基础的无线投屏协议。Miracast采用的技术都来自Wi-Fi联盟的电子制造商和芯片制造商的团队研发,其兼容性和广泛应用性无可厚非,英伟达、英特尔、德州仪器包括国内联发科等芯片制造商都已支持Miracast协议。Miracast无线投屏是兼容性最广的投屏协议,国内大多数Android手机、智能电视都支持Miracast投屏协议。它仅需要手机和电视支持Miracast投屏协议,并且手机和电视处于同一局域网内,即可通过Miracast将视频或照片直接在电视或其他设备播放。Miracast 不是设备或软件,而是 Wi-Fi Alliance 规范下的一项技术的名称。以上两项技术,是应用最广的。

UWP应用可以使用Windows.Media.Casting命名空间下CastingDevicePicker类接受Miracast数据:
Windows.Media.Casting 命名空间 - Windows UWP applications | Microsoft Learn
,WPF也可以使用WindowsXamlHost承载画面

详细的可参考某个大佬的文章:
一文带你详尽剖析Miracast投屏开发和调试_android miracast 开发-CSDN博客

以上这俩个协议是应用最多的,私有投屏协议考虑兼容的话,笔记本、手机投过来接收端兼容这俩个就够了。

DLNA
协议是索尼、英特尔、微软等发起的一套 PC、移动设备、消费电器之间互联互通的协议,这是一个早期的标准。支持在家庭网络中共享多媒体内容,许多智能电视和家庭影院系统支持DLNA,DLNA与苹果的AirPlay比较相似,都可以让你手机中的媒体内容投放到电视屏幕里。不同的是手机上的DLNA 并没有类似AirPlay或Miracast的投屏镜像功能。而是相当于从一个设备的本地存储中拿内容到另外一个设备上去display(展示),并且不影响当前设备的其他操作。目前DLNA只支持将手机的照片和视频投送到大屏幕中。

Google Cast (Chromecast)
协议是谷歌开发的无线投屏技术。通过Google Cast,可以将多媒体内容从移动设备或PC传输到电视或音响设备。与AirPlay相比,Chromecast体验却大不相同。相比镜像投屏,Chromecast体验更接近于DLNA。

WiDi(Wireless Display)
由Intel开发,是一种支持Windows10笔记本无线投屏方式,无需安装软件,即可无线投屏。

HDMI
协议是HDMI传输解决方案,也叫有线投屏,能够实现无损传输,但成本较高,发射端需要独立供电,并且需要无障碍传输。HDMI线一般是几米长,也有15米的线。10米以上传输稳定性可能有一定风险,超长线建议与设备高压验证后再导入。我这边对接的京东,他们自己研发软件投屏用于内部员工投屏,访客采用有线HDMI方式,场景基本就覆盖了。

私有投屏协议
是各公司自有的解决方案,种类繁多,产品形态以安装软件为主,手机需要安装APP,网络通即可投屏

我了解到在自研投屏的公司就有:CVTE、宜享、海信

宜享 -- 海信、华为、H3C大屏都是OEM贴牌宜享的产品,宜享也有公版产品,京东可以买到

海信,应该是2023年(时间我不确定哈、但2021年确定还是OEM的)开始自研投屏了,2024年初招聘网站上有招聘传屏专家岗位。

毕竟投屏是大屏最核心的功能场景,稳定性以及用户体验还是要抓在自己手里,还能省成本不是,基于wifi6的typec投屏器也要200以上人民币

当然投屏还是比较复杂的,要做软件还要做硬件投屏器,整个链路有采集、编解码、传输、显示,目前主流最新技术要支持,如BYOM最大化利用大屏设备、超声波自动完成设备配对,软件要支持安卓、Windows、Linux信创以及未来确定的鸿蒙。开发成本比较大

Mac上HomeBrew安装及换源教程

Mac的Mac OS系统来源于Unix系统,得益于此Mac系统的使用类似于Linux,因此Linux系统中的包管理概念也适用于Mac,而HomeBrew便是其中的一个优秀的包管理工具,而包管理工具是什么呢?软件包管理工具,拥有安装、卸载、更新、查看、搜索等功能,在终端中通过简单的指令可以实现各类功能包的管理,而不用关心各种依赖和文件路径情况。因此无论是什么驱动?开发工具?都可以在HomeBrew中进行快捷下载而不像Win下有着繁杂的环境管理。

安装教程

想要快速的检查电脑中有无HomeBrew只需要一行命令

brew -v #检查电脑是否存在HomeBrew

如果终端打印了版本信息的话证明电脑中存在HomeBrew,如果打印未知命令的话代表电脑中没有HomeBrew环境。

类似于机器人开发中的鱼香ROS可以一键安装需要的环境,HomeBrew也提供了一键安装的脚本以供用户一键进行安装:

· 如果需要重新安装请先卸载HomeBrew:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)"

· 一键安装的命令(可能需要Science On The Net):

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

如果遇到这个报错:curl: (7) Failed to connect to raw.githubusercontent.com port 443: Connection refused则代表网络无法访问需要Science On The Net,一般来说XXX网后这个安装和下载的速度非常的迅速

附上常用的HomeBrew指令

· 想要查找HomeBrew的用户帮助界面可以输入

brew -h 
brew help

· 查看HomeBrew的版本

brew -v

· 更新HomeBrew

brew update

HomeBrew换源命令

HomeBrew默认的源在国外,平时正常使用非常的慢因此我们可以将其替换为国内源

· 查看当前源

cd "$(brew --repo)" && git remote -v

替换为清华源

# 替换各个源
$ git -C "$(brew --repo)" remote set-url origin https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/brew.git
$ git -C "$(brew --repo homebrew/core)" remote set-url origin https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/homebrew-core.git
$ git -C "$(brew --repo homebrew/cask)" remote set-url origin https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/homebrew-cask.git

# zsh 替换 brew bintray 镜像
$ echo 'export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.tuna.tsinghua.edu.cn/homebrew-bottles' >> ~/.zshrc
$ source ~/.zshrc

# bash 替换 brew bintray 镜像
$ echo 'export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.tuna.tsinghua.edu.cn/homebrew-bottles' >> ~/.bash_profile
$ source ~/.bash_profile

# 刷新源
$ brew update

替换为中科大源

# 替换各个源
$ git -C "$(brew --repo)" remote set-url origin https://mirrors.ustc.edu.cn/brew.git
$ git -C "$(brew --repo homebrew/core)" remote set-url origin https://mirrors.ustc.edu.cn/homebrew-core.git
$ git -C "$(brew --repo homebrew/cask)" remote set-url origin https://mirrors.ustc.edu.cn/homebrew-cask.git

# zsh 替换 brew bintray 镜像
$ echo 'export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles' >> ~/.zshrc
$ source ~/.zshrc

# bash 替换 brew bintray 镜像
$ echo 'export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles' >> ~/.bash_profile
$ source ~/.bash_profile

# 刷新源
$ brew update