Spring事务的那些坑

图片来自pixabay.com的distelapparath会员

Spring MVC框架对数据访问事务抽象封装在spring-tx模块中,一个简单@Transactional注解就可以对一个方法调用开启事务,在使用过程中,若对Spring MVC的事务配置和原理了解不够清楚,一不小心就会落入坑中。

注:本文的代码技术讨论基于Spring Framework Tx 5.2.6.RELEASE版本 + Spring Boot 2.2.7.RELEASE版本。

1. 坑1 - 事务方法调用过程中的异常抛出

对一个方法调用开启事务,是希望在运行过程中,一旦有未知异常发生,则需要回滚数据库表的相关写操作,保证数据操作的原子性和一致性。

下面的insertUser()方法中,首先对一个user记录进行了保存写入操作,然后有一个异常被抛出,这个时候user记录是否会被回滚?

@Service
public class UserService {

    @Autowired
    UserInfoDAO userInfoDAO;

    @Transactional
    public void insertUser(UserInfoDTO user) throws Exception {
        userInfoDAO.insert(user);
        throw new Exception("Oh, an error happened");
        return user;
    }

}

答案其实是不会,user记录还是被保存到了数据库表中。其原因是@Transactional在未配置任何rollbackFor异常类时,将会执行如下缺省配置,即只对RuntimeException和Error两种异常发生时才执行回滚操作,上面的代码中抛出的异常类是Exception,未命中回滚条件。

public class DefaultTransactionAttribute extends DefaultTransactionDefinition implements TransactionAttribute {

    public boolean rollbackOn(Throwable ex) {
        return ex instanceof RuntimeException || ex instanceof Error;
    }

}

若希望在上面的情况下,回滚user记录的写入操作,一个解决办法是,在标注Transactional时指定如下的回滚异常类,

@Transactional(rollbackFor = Exception.class)

更多的Transactional注解配置说明见下文。

2. 坑2 - 内部调用方法被标注为事务

在下面的代码样例中,UserService提供了两个方法,

  • public void insertUser(UserInfoDTO user)
  • private void insertUserInner(UserInfoDTO user)

前一个方法将会调用后者,后者执行数据记录保存操作,@Transactional注解标注在后者方法上。

@RestController
public class TestController {

    @Autowired
    private UserService userService;

    @RequestMapping(value = "/test", method = RequestMethod.POST)
    public UserInfoDTO addUser(@RequestBody UserInfoDTO user) throws Exception {
        return userService.insertUser(user);
    }

}


@Service
public class UserService {

    @Autowired
    UserInfoDAO userInfoDAO;

    public void insertUser(UserInfoDTO user) throws Exception {
        insertUserInner(user);
    }

    @Transactional
    private void insertUserInner(UserInfoDTO user) throws Exception {
        userInfoDAO.insert(user);
        throw new RuntimeException("Oh, an error happened");
    }

}

若在insertUserInner方法中出现异常,上面的user记录同样也不会被回滚,原因是,
1. Transactional的实现是通过Spring AOP原理,由于Spring AOP对内部方法调用不起作用,数据事务也无法正常启动。
2. @Transactional注解只对public方法起作用,对于private/protected/package-visible的方法上是无效的。

要想对内部调用或私有方法启用数据事务,可以考虑使用可编程的数据事务

3. 坑3 - 在一个事务方法调用了另外一个事务方法

在下面的代码样例中,事务方法UserAService.insertUser()调用了另外一个UserBService.insertUser()事务方法,并通过try/catch对后者的异常进行了日志输出处理。

@Service
public class UserAService {

    @Autowired
    UserBService userBService;

    @Autowired
    UserInfoDAO userInfoDAO;

    @Transactional()
    public void insertUser(UserInfoDTO user) throws Exception {
        // 保存用户记录A
        userInfoDAO.insert(user);

        try {
            userBService.insertUser(user);
        } catch (Throwable e){
            System.out.println(e.toString());
        }
    }

}

@Service
public class UserBService {

    @Autowired
    UserInfoDAO userInfoDAO;

    @Transactional()
    public void insertUser(UserInfoDTO user) throws Exception {
        // 保存用户记录B
        userInfoDAO.insert(user);
        throw new RuntimeException("Oh, an error happened");
    }

}

在UserBService.insertUser()方法执行过程中,若有未知异常发生,用户记录A和用户记录B在数据库表中的状态是怎样?是保存,还是回滚了?

有很多人认为,由于异常被try/catch了,所以用户记录A将会被正常保存,而用户记录B会被回滚,而事实上是,两个用户记录都未被保存到。

查看日志可以看到如下的报错信息,

org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only at 
org.springframework.transaction.support.AbstractPlatformTransactionManager.processRollback(AbstractPlatformTransactionManager.java:870)at 
org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:707) at 
org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:385) at 
org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:118) at 
org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) at 
com.pphh.demo.service.UserAService$$EnhancerBySpringCGLIB$$73a6d5aa.insertUser(<generated>) ~[classes/:na]

这个异常日志说当前事务已回滚,无法执行当前事务。查看整个事务执行过程,就可以了解这个报错的原因,
- UserAService.insertUser被调用,开启了事务A,保存用户记录A
- UserBService.insertUser被调用,由于当前已经有存在事务,根据Transactional方法默认事务传播定义,是需要加入到当前事务A
- UserBService.insertUser异常发生,回滚当前事务A,用户记录B被回滚
- UserAService.insertUser执行完毕,准备提交用户记录A,这时发现当前事务A已经被回滚,无法执行当前事务。

要想解决这个问题,让用户记录A将会被正常保存,而用户记录B被回滚,可以将UserBService.insertUser的事务传播定义为Propagation.NEW,

public class UserBService {

    @Transactional(propagation = Propagation.NEW)
    public void insertUser(UserInfoDTO user) throws Exception {
        // 保存用户记录B
        userInfoDAO.insert(user);
        throw new RuntimeException("Oh, an error happened");
    }

}

4. 开启事务调试日志

有时候为了方便查看事务运行情况,可以通过下方法开启事务日志,

# transaction
logging.level.org.springframework.transaction.interceptor=TRACE

如下是一段Trace执行日志,可以看到由于没有回滚规则定义,直接使用了缺省规则。

2020-05-26 12:16:14.329 o.s.t.i.TransactionInterceptor           : Getting transaction for [com.pphh.demo.service.UserService.insertUser]
2020-05-26 12:16:14.490 o.s.t.i.TransactionInterceptor           : Completing transaction for [com.pphh.demo.service.UserService.insertUser] after exception: java.lang.Exception: Oh, an error happened
2020-05-26 12:16:14.490 o.s.t.i.RuleBasedTransactionAttribute    : Applying rules to determine whether transaction should rollback on java.lang.Exception: Oh, an error happened
2020-05-26 12:16:14.490 o.s.t.i.RuleBasedTransactionAttribute    : Winning rollback rule is: null
2020-05-26 12:16:14.490 o.s.t.i.RuleBasedTransactionAttribute    : No relevant rollback rule found: applying default rules

5. Transational注解配置说明

下面对Transational注解的各个配置定义进行了说明,供参考,

public @interface Transactional {

    // 指定事务管理器
    @AliasFor("transactionManager")
    String value() default "";

    // 同上
    @AliasFor("value")
    String transactionManager() default "";

    // 事务嵌套时,事务的传播定义,有如下几种,
    // Propagation.REQUIRED      -- 当前若有事务存在,则加入该事务。若无事务,则创建一个事务。
    // Propagation.SUPPORTS      -- 当前若有事务存在,则加入该事务。若无事务,则继续非事务状态运行。
    // Propagation.MANDATORY     -- 当前必须在事务中,若当前无事务,则抛出异常。
    // Propagation.REQUIRES_NEW  -- 当前方法新起一个事务。当前若有事务存在,则挂起当前事务。
    // Propagation.NOT_SUPPORTED -- 当前方法不支持事务,若有事务存在,则挂起当前事务。
    // Propagation.NEVER         -- 当前方法不支持事务,若有事务存在,则抛出异常。
    Propagation propagation() default Propagation.REQUIRED;

    // 事务隔离级别,有如下几个定义,该选项只适用于REQUIRED和REQUIRES_NEW事务传播级别
    // DEFAULT          -- 数据库缺省
    // READ_UNCOMMITTED -- 读未提交
    // READ_COMMITTED   -- 读已提交
    // REPEATABLE_READ  -- 可重复读
    // SERIALIZABLE     -- 串行
    Isolation isolation() default Isolation.DEFAULT;

    // 事务超时时间,缺省是数据库指定,该选项只适用于REQUIRED和REQUIRES_NEW事务传播级别
    int timeout() default -1;

    // 只读事务,该选项只适用于REQUIRED和REQUIRES_NEW事务传播级别
    boolean readOnly() default false;

    // 事务回滚异常类
    // 多个回滚条件可以通过如下指定
    // @Transactional(rollbackFor = {Exception.class, Error.class})
    Class<? extends Throwable>[] rollbackFor() default {};

    // 事务回滚异常类,必须继承自Throwable
    String[] rollbackForClassName() default {};

    // 事务回滚异常类过滤条件
    Class<? extends Throwable>[] noRollbackFor() default {};

    // 事务回滚异常类过滤条件,必须继承自Throwable
    String[] noRollbackForClassName() default {};
}

6. 参考资料

如何挑选健康体检套餐?

图片来自pixabay.com的designerpoint会员

市场上体检套餐品种繁多,琳琅满目,不说什么入职、白领精英、中青年等套餐分类,就是想给父母选择一个体检套餐,还分为“孝心基础”、“感恩深度”、“全面至尊”等等供选择,价格从低到高都有,这真是让作为子女的我们挑花了眼,费尽了心思,打开任一份体检套餐看,体检项一长串,什么心脑血管、糖尿病检查、肿瘤筛查,应有尽有,如何选择一个合适的体检套餐呢?

为了能够回答这个问题,本文将先介绍两个强有力的套餐对比手段:性价比值、体检项覆盖率,然后利用这两个手段去分析市场上收集的近20套体检套餐,这些套餐包括了不同价位、不同渠道、不同套餐类型,让我们看看各个价位、渠道的体检套餐区别,最后将给出该如何选择一份合适的体检套餐的最佳建议。

1. 如何对比体检套餐

我们一般比较关心体检套餐两个方面的指标,

  1. 是否值这个钱?
  2. 它的体检项覆盖够不够全面?

对于一个便宜的体检套餐,体检项可能只有区区几个,体检可能太简单了,有些健康问题覆盖不到。而一个很贵的体检套餐,虽然所有体检项都覆盖到了,但是价格虚高,也不是我们想要的。我们往往需要的是一个高性价比、高覆盖率的体检套餐。

先看看如何衡量体检套餐的性价比、覆盖率。

1.1 性价比值R

一个体检套餐,有一个标示价格VB,就是我们购买这个体检套餐所需要支付的价格,这个价格很好获取。

为了了解体检套餐的真实价值,我们还要获取一个标准价格,这个价格对于所有体检套餐都具有有统一的衡量标准,由于绝大多数体检项其本身就是一个标准项,比如血常规,在任何一个医院和体检机构,其检查项和医学意义都相同,因而我们可以以如下方法获取一个体检套餐的标准价格VS,

体检套餐公式之标准价格VS

其中V为第i个体检项的市场平均价格,每个体检项的参考价格可以参考《体检套餐常见体检项》一文。标准价格其本质含义是:将体检套餐拆开,把每一项体检项按照市场价格一一计算所需支付的钱。

在获取到标示价格和标准价格后,我们就可以按照下面的计算公式,获取这个套餐的性价比值R,

体检套餐公式之性价比值R

可以注意的是,

  1. 一般来说,一个体检套餐的标示价格VB会小于标准价格VS,因为作为一个体检套餐,其整体价格应该比拆开每一个体检项更加优惠,因此性价比R一般大于1。
  2. 两个体检套餐若体检项相同,则标准价格VS一定相同,而这个时候性价比R的高低就看标示价格VB了。
  3. 从另一个角度,性价比R说明的是该体检套餐能够为我们省多少钱,一个标价为100元的体检套餐,若其性价比R为2,则标准价格VS为200元,这个体检套餐能够帮我们省高达50%(即100元)的钱。因此,体检套餐的性价比R越高,则省钱比例越大。

1.2 体检项覆盖率C

为了衡量一个体检套餐的体检项覆盖率,我们需要知道完整的体检项列表。这个列表其实没有标准答案,本文将《体检套餐常见体检项》一文列举的体检项列表作为对标对象,覆盖率计算公式如下,

体检套餐公式之体检项覆盖率C

其中,NP为体检套餐的体检项计数,NS为所对标的完整列表的体检项计数。

可以注意的是,

  1. NS为一个常量值,其由对标的完整列表所决定。
  2. NP小于或等于NS,也就是说,覆盖率C最大为100%,最小为0%。
  3. 一般来说,体检套餐越贵,其体检项NP就越多,其覆盖率C会越大。
  4. NS的计算会比较复杂,原因有两个:
    • 在完整的体检列表中,有些体检项属于重复体检项,比如幽门螺旋杆菌,其有三种检测手段(血液、C14呼气、C13呼气),在计算时,需要减去重复项计数
    • 有些体检项其覆盖面会较广,比如肿瘤蛋白芯片C6,其能够一次检测多项肿瘤标志物情况,而有些体检项其覆盖范围较小。为了平衡这种情况,每个体检项有计数权重,比如肿瘤蛋白芯片C6的计数权重为6,而胸部DR侧位片的权重为0.5(因为胸部DR一般拍摄正位)。
      因此NS是需要经过重复项剔除和权重计数修正,从而使得计算会更加准确。
  5. NP在统计时,也需要经过和上述相同的重复项剔除和权重计数修正。

2. 体检套餐的统计分析

根据上文给出的体检套餐性价比R、覆盖率C的定义,下面我们可以对市场上的各个体检套餐进行相应地统计分析。

2.1 体检套餐的收集

为了让统计更加有意义、分布更加有代表性,一共收集有如下18个体检套餐,

体检套餐之套餐列表

这18个体检套餐分别来自三个主要体检机构(用字母M、A、R表示),及其来自三种销售渠道(公司团检套餐、京东旗舰店、天猫旗舰店),价位也覆盖了从150到3650元范围。

其机构和渠道分布比例见下图,

体检套餐之机构和渠道分布

2.2 体检套餐的性价比R、覆盖率C

各个体检套餐的性价比R、覆盖率C计算分析结果见如下表1,

体检套餐之RC对比
表1:各个体检套餐的性价比R和覆盖率C

3.体检套餐的对比

3.1对比分析一:不同价位套餐

将表1中的相同价位的套餐各个数据相加再取平均值,则可以得到各个套餐价位的平均性价比R和覆盖率C情况,如下表2所示,

体检套餐之平均RC
表2:各个套餐价位的平均性价比R和覆盖率C

3.1.1 不同价位套餐的性价比趋势

各个价位的性价比趋势见下图,

体检套餐之平均R趋势

可以看到,最佳性价比出现500元价位,在这个价位出现性价比的高点,然后随着套餐价格的升高,性价比逐步回落,2000元价位的性价比跌落至1.27。

3.1.2 不同价位套餐的体检项覆盖率趋势

各个价位的体检项覆盖率和体检套餐平均价格相互关系可见下图,

体检套餐之平均覆盖率C趋势

可以看到,随着体检套餐价格的上升,其体检项覆盖率也逐步上升。但是,随着体检套餐覆盖率线性上升,套餐价格却急剧增加。从200元到500元,覆盖率上升了20%,多花了526-178=348元;而从1000元到2000元,覆盖率也上升了同样的20%,却需要多花2728-1080=1648元,其主要原因是,低价位的体检套餐主要覆盖了较为便宜的常规体检项,而较贵的体检项则需要高价位的体检套餐才能承担。

3.1.3 不同价位套餐的体检项覆盖情况分析

下面是对于各个价位体检套餐,其体检项覆盖情况的详细分析,

200元价位

一般能够覆盖20%的体检项,普通的常规检查都有(一般体检、内外科、血常规、尿常规、肝功能、肾功能、血脂、血糖、心电图、妇科常规、胸部DR正位),但常规检查有挑选主要项目,并非完整的常规检查项,比如肝功能四项,好一点的体检套餐会有腹部超声波检查。

500元价位

一般能够覆盖50%的体检项,完善的常规检查项,然后又较为完善的超声波检查(胸、心脏、甲状腺、前列腺、子宫及附件等),胸部正位和颈椎侧位,肿瘤蛋白芯片检测,还有一些实验室特别检查,检查心脑血管风险、风湿因子等等

1000元价位

一般能够覆盖60%的体检项,完善的肿瘤标志物检测,添加一些较贵的实验室特别检查项,比如甲状腺检查、胃功能、心肌酶检查,升级检查手段(比如宫颈刮片升级为TCT、幽门螺旋由血液检测升级为C14、C13)、骨密度测定、经颅多普勒、胸部CT或颅脑CT

2000元价位

一般能够覆盖80%的体检项,除了上述体检项,新增的体检项包括,

  • 颈动脉和心脏彩超
  • 血清中的同型半胱氨酸检测
  • 微量元素检测
  • 胰岛素检测
  • 胸部CT、颅脑CT
  • 磁共振血管成像MR
  • 胃肠镜检查

上述体检项单价都价格不菲,因此也带动套餐价格迅速上升。

对于各个体检项,若按体检单项平均价格从低到高排序,将分别为,

  1. 一般检查
  2. 妇科检查
  3. 实验室特别项检查
  4. 肿瘤标志物检查
  5. 超声波检查
  6. CT检查、磁共振血管成像MR
  7. 电子肠胃镜检查

这些体检项目一般出现的体检套餐价格区间如下图所示,

体检套餐之价格区间

一个可注意的事情是,一般单价高的体检项,其人工耗时也非常高,比如超声波检查、CT检查。

3.2 对比分析二:同一体检机构,不同销售渠道

将表1中的M体检机构的套餐摘选出来,按不同销售渠道归类,列举为下表,

体检套餐之不同销售渠道RC
表3:M体检机构各个体检套餐的性价比R和覆盖率C

可以看到,京东和天猫M体检机构旗舰店的套餐性价比高低不一,其中“M-天猫-精英白领”这个套餐的性价比还跌破1,也就是说这个套餐还不如单个体检项一一检查的价格,这个套餐的价格水分已经非常离谱了。而公司团检渠道的三个体检套餐,其性价比都超过了2,最佳性价比达到了2.88。

体检套餐之不同销售渠道RC趋势图

由此可见,公司团检的体检套餐性价比非常高,价格相当靠谱、厚道。

虽然各个电商平台上的套餐活动花样多,逢双十一活动大促,“满300减50”,“买一送一”,但是这个套餐价格是有大量水分的,挑来挑去,一不小心就花了冤枉钱。

4.选择体检套餐的最佳建议

综合上面各个对比分析结果,我们可以总结出如下几个购买体检套餐的最佳建议,

  • 公司团检的体检套餐性价比会比较靠谱,尽量让家人随公司团检一起去检查。
  • 从价位上来说,性价比的高点出现在500元价位,虽然体检项覆盖率一般在50%左右,但是已经能覆盖大多数疾病的健康筛查,而对于特别检查项,家人可以根据检查结果,去医院专科随诊,深入检查。
  • 若想较完善的体检套餐,可以选择1000元价位,这个价位的套餐会覆盖更多、更完善实验室特别检查项,这比起自己去医院检查,能省下不少的钱。
  • 避免选择贵的体检套餐,除非对这个套餐有深入的了解。一般购买1500元以上体检套餐的人,对价格不敏感,注重的是流程体验,希望轻松快捷,因而其这个价位的体检套餐以定位于VIP套餐和礼品套餐为主。
  • 避免选择水分大的,比如有买一送一的套餐活动,这个套餐,很容易落入价格陷阱。

体检套餐常见体检项

图片来自pixabay.com的NickRivers会员

本文将梳理体检套餐中常见的100多个体检单项,并按如下进行归类成列表,

  1. 一般检查
  2. 实验室常规检查
  3. 实验室特别检查
  4. 肿瘤标志物检查
  5. 妇科检查
  6. 影像检查
  7. 其它

体检项的市场参考价格和临床医学意义也一并给出,其中市场参考价格来自于各大医院的平均公示价格,这些列表数据将有助于分析体检套餐的成本价格和检查项完整度。

详细数据请见下图,

体检检测项完整列表-v5

对于表中数据,需要特别关注如下几点,

  • 注1:表中只列举出体检套餐中的常见体检项。
  • 注2:可以看到每个体检单项价格不一,一般来说常规体检项比较便宜,而实验室特别检查、影像检查等将较贵。对于各个体检项,若按体检单项平均价格从低到高排序,将分别为,
    1. 一般检查
    2. 妇科检查
    3. 实验室特别项检查
    4. 肿瘤标志物检查
    5. 超声波检查
    6. CT检查、磁共振血管成像MR
    7. 电子肠胃镜检查
  • 注3:每个体检项由于体检检测手段、检测设备不同,价格可能有较大变化,表中参考价格取自平均价格。例如电子胃镜,可以分为普通电子胃镜、无痛电子胃镜、胶囊电子胃镜,这三种电子胃镜的检测手段和设备不同,导致价格相差会比较大。在计算和比较体检套餐价格时需要多加注意这些区别。