背景和问题

情况是这样的:
由于某业务上的需求,需要数据库表T的数据发生变化时,通知给其它系统。
改造前,在各数据发生变化的业务节点,直接调用通知方法,将数据同步给其它系统;
改造后,使用了spring aop,基于抽象出的update方法,进行数据同步。

于是代码改成了这样:

##切面类
class NotifyXSystemAOP{
    //切面定义
    @Pointcut("execution(* com.xxx.updateMethod(com.xxx.XBean)) && args(record)")
    public void updatePointcut(XBean xBean){}

    //切面逻辑
    @After(value = "updatePointcut(record)",argNames = "xBean")
    public void afterUpdate(XBean xBean){
        //要将全量数据同步给兄弟系统,所以查了库
        xBean = xBeanMapper.selectByPrimaryKey(xBean.getId());
        
        //利用线程池,将数据同步至兄弟系统`XSystem`
        ThreadManager.executeTask(()-> {
            sendToXSystem(xBean);
        });
    }
}

##业务类关键方法
public void keyMethod(){
    ...
    xBean.setPropertyA(999);
    com.xxx.updateMethod(xBean);    //修改动作
    ...
}

看着一副还算合理的样子,测试时却发现推送的xBean对象与数据库表T中的数据不一致(比如库中PropertyA字段=999,推送数据PropertyA=1

分析

发现问题后,先检查了切面中的逻辑,发现 切面中确实没有任何修改XBean的痕迹
而且从日志观察,业务中的修改方法确实已经成功执行了,才进入了切面。
所以越发的觉得这个问题很诡异!

推理1:事务设置

众所周知,mysql默认的事务隔离级别是可重复读(Repeatable read),有没有可能是这样的?
业务方法中执行了修改,由于事务还没提交,所以切面中查询时没有获取到最新的属性值,而把旧版本数据推送给兄弟系统了。

有了思路,就进行求证喽。于是在切面方法中增加了日志:

@After(value = "updatePointcut(record)",argNames = "xBean")
public void afterUpdate(XBean xBean){
    log.info("查询前xBean={}",xBean);    //增加日志——查询前
    xBean = xBeanMapper.selectByPrimaryKey(xBean.getId());
    log.info("查询后xBean={}",xBean);   //增加日志——查询后

    ThreadManager.executeTask(()-> {
        sendToXSystem(xBean);
    });
}

如果确实如我们猜测,问题是由事务引起的,那么查询后和查询前的PropertyA属性值会不一样。
重新执行,观察日志,结论是:查询前后,PropertyA的值一样!也就证明——推论错误,凶手不是事务。

其实仔细想想,也能想明白。
代码里是在切面主线程中做的数据库查询。而切面AOP的本质是动态代理,那么一个代理对象,又在同一个线程中,先执行业务方法逻辑,再执行切面中切入的逻辑,如果没有什么特殊的事务控制,这些逻辑理应在一个事务当中。

通过新增的日志,发现xBean确实已经修改过了——新增日志中的xBean和数据库表的数据完全一致。
问题在于,只是到推送时(sendToXSystem(xBean)方法处),某些属性,如PropertyA就发生了改变。

推理2:对象传递

于是有了第二种猜测,会不会切面在推送数据过程中,业务方法中有修改逻辑

先来复习一波java参数传递的问题:

# 参数传递demo
void methodK(){
    Person person = new Person();
    person.setName("zhang3");

    changeName(person);    //传递person对象
    System.out.println(person);    //重写了person的toString方法
}

void changeName(Person person){
    person.setName("li4");    //方法内部修改了name属性
}

程序并不复杂,输出结果中:

name=li4

这不难解释,由于changeName方法中的person和methodK方法中的person指向同一个对象,所以changeName的修改会导致name属性的变化。
clipboard.png

基于此,我猜测问题就出在这里,于是搜索了业务方法:

##业务类关键方法
public void keyMethod(){
    ...
    xBean.setPropertyA(999);
    com.xxx.updateMethod(xBean);    //修改动作
    ...
    xBean.setPropertyA(1);    //真的找到了这货
}

正所谓“成也参数传递,败也参数传递”。
思考一下好像有些不对,因为在切面方法中,一开始就重新修改了xBean对象的引用:

xBean = xBeanMapper.selectByPrimaryKey(xBean.getId());    //查库,修改了xBean的引用

还用前面的demo模拟,这相当于作了如下修改:

# 对象传递demo
void methodK(){
    Person person = new Person();
    person.setName("zhang3");

    changeName(person);    
    System.out.println(person);
}

void changeName(Person person){
    person = new Person();    //修改引用
    person.setName("li4");    
}

执行结果会变成:

name=zhang3

也就是说,随着changeName方法重新修改了引用指向,致使changeName方法中的person和methodK方法中的person指向不同对象,因此changeName不能再改变methodK-person指向的对象了

clipboard.png

这个理论同样适用于我们遇到的问题,所以这也不能解释为什么推送数据发生变化了。

推理3:真正的凶手

不死心的我再次翻看了业务方法:

##业务类关键方法
public void keyMethod(){
    ...
    xBean.setPropertyA(999);
    com.xxx.updateMethod(xBean);    //修改动作,触发切面
    ...
    xBean.setPropertyA(1);    //赋值
}

PropertyA最终被赋值为1,而切面中最终推送的数据里PropertyA也等于1
这有些过于巧合了吧,于是耐着性子再仔细找找:

##业务类关键方法
public void keyMethod(){
    ...
    xBean.setPropertyA(999);
    com.xxx.updateMethod(xBean);    //修改动作,触发切面
    ...
    xBean = xBeanMapper.selectByPrimaryKey(xBean.getId());    //又找到了这个
    ...
    xBean.setPropertyA(1);    
}

业务方法中,调用了和切面中同样的查询方法。我们用的orm框架是mybatis,而mybatis似乎有个一级缓存?

科普下什么是mybatis的一级缓存:

  • 在同一个sqlsession中,如果某查询sql已经执行过了,会将结果缓存,再次执行此查询sql时直接从缓存中获取结果,以提升性能。(当然,update等操作会作废一级缓存)

结合spring之后,sqlsession默认会和事务绑定。

那么情况应该是这样的:
代码中切面先从db中查询了结果,放入一级缓存中;之后业务逻辑再次执行同样的sql,由于缓存中已有结果,直接从缓存中获取(这里获取的对象和切面中相同)。之后业务逻辑重新赋值PropertyA,再之后切面中用另一个线程推送被修改的对象,完成绝杀。

解决方式简单粗暴,直接在切面中搞个副本就好:

@After(value = "updatePointcut(record)",argNames = "xBean")
public void afterUpdate(XBean xBean){
    xBean = xBeanMapper.selectByPrimaryKey(xBean.getId());
    
    xBean = BeanUtils.cloneBean(xBean);    //查询后创建副本

    ThreadManager.executeTask(()-> {
        sendToXSystem(xBean);
    });
}

最后总结一下吧:
这是一篇以aop勾引你进来,主打惊悚悬疑,中间夹杂事务隔离级别、对象参数传递和mybatis一级缓存的奇葩文章……


青鱼
268 声望25 粉丝

山就在那里,每走一步就近一些