上周测试同学反馈的一个 BUG,订单根据用户姓名查询不出来,我本地复现后发现情况更复杂,问题具体表现为:多次查询,大部分情况下都无法查询数据,偶尔可以查询到数据。
问题分析
排查后发现与我们项目中使用的 MyBatis 加解密拦截器有关,加解密拦截器的作用是敏感字段储存数据库的时候会进行加密,查询的时候会对数据库的中的加密数据进行解密,如果查询条件中包含敏感字段,会先将敏感字段加密后再到数据库中进行查询。大致执行流程为:
要注意的是这个项目的加解密拦截器在执行完成之后是并不会将加密参数进行恢复的。
但由于当前分库分表在多路由(多库多表)场景下会多次执行 Mapper,会导致 Mapper 中的查询参数多次重复加密。
比如现在查询条件是 UserQuery
类,包含一个 name
属性,第一次执行 Mapper 的时候需要对 userQuery
对象中的 name
进行加密,第二次执行 Mapper 的时候,由于查询条件还是 userQuery
对象(此时其中的 name
是已经加密过了),但是加解密拦截器还是会执行,从而导致 name
属性被重复加密。
也就是说大部分情况下是只有第一次加密才会成功,这里为什么说是大部分情况呢,首先可以将加密这个操作进行分解:
- 从
userQuery
中获取 name
-
name
加密 -
name
赋值到 userQuery
中
由于是多线程去执行 Mapper,也就是说是多线程并行执行上面三个操作,其实只要第一步取到的 name
是未加密过的数据,那么就能保证当前 Mapper 的查询条件对象的加密结果是正确的。这也就能从一个方面解释为什么 会出现偶尔查询到数据,偶尔又查询不到。
知道了问题所在,那么接下来就是要想办法去解决。其实思路很多,这里举几个解决过程中的思路。
根据数据判断是否加密
可以考虑在第二步对 name
进行加密之前判断一下当前 name
的数据是否已经加密过了,如果已经加密了那就不进行加密。
那么怎么判断是否已经加密过了呢,我们可以在数据加密后加上一个标识,比如数据“张三”加密后是"abcd",可以在加密数据后增加一个标识,如变成“MD5@abcd”,这样就可以判断数据是否是当前加密算法后的加密数据。
但是这个方案需要刷数据库的数据,新项目可以考虑这种方式,老项目这个方案就不太合适。
锁+本地缓存(类 DCL)
这个也是跟组内同学讨论出来的一个 方案。
这个问题就是上面的三个步骤在多线程下会出现并发问题,那么就可以考虑加锁(可能有的朋友会觉得加锁会不会造成性能问题,其实这里完全不用担心,锁只要不滥用,对性能的影响是可以忽略的),保证加密方法同时只会被一个线程执行,这里也要加一个本地标识,保证只加密一次,整体流程如下:
看着是不是很像 DCL 问题,即双重检查锁:
流程已经清晰了,接下来还有几个地方考虑。
用哪个锁
我这里指的就是是使用 synchornized
还是 ReentrantLock
。首先没有必要纠结于性能,这里主要考虑的点是哪个更好用。
使用 ReentrantLock
的话就得多线程去传递 ReentrantLock
的引用,可以在多线程执行 Mapper 之前把它 set
到 ThreadLocal
中去,感觉还是麻烦了点,我个人倾向于使用 synchornized
,锁的对象就是当前 Mapper 的查询参数对象。
关于锁的粒度
这里加密操作有三个步骤,是否要将三个操作都加密呢?我觉得是的,因为这个场景下不是考虑锁持有时间长短的问题,而是确保只有一个线程去进行加密操作。没有必要考虑那么细。
怎么缓存
其实这个时候缓存的内容说白了就是这个加密对象,缓存中已经有了就证明已经加密过了,很自然的就想到了对象的 hashcode
,但是我们无法确定是否这个对象重写了 hashCode()
方法,所以这里可以使用 java.lang.System#identityHashCode
方法进行处理。
缓存清理
那么缓存什么时候清除呢,很自然是当前 SQL 操作所有的线程都执行完成后进行清除,可以使用 Guava 的有过期时间的缓存,或者手动在所有线程执行完成后进行缓存清理。
增加是否加密标识
这个方案就很好理解了,比如现在要执行 Mapper 方法 5 次。那么就先同步执行第一个,让它去加密操作,剩下的 4 次 Mapper 操作就多线程执行,执行前在 ThreadLocal
中设置一个是否加密过的标识,加解密拦截器执行的时候先判断标识,如果已经加密过了,就放弃本次加密。
最后
这个问题是在分库分表中间件测试过程中第一个有点意思的 BUG,这里就做一个记录。