一【问题】
因为有些敏感字段是必须加密存储的,为了不让数据安全存储的要求影响正常的业务逻辑,就写了个MyBatis插件来解决这个问题。之前一直都没出什么问题,可是后来有同事告诉我这个插件有时会出现解密异常的错误。
二【分析】
因为这个插件已经在线上跑了大半年了,一直都很稳定,所以我想肯定不是加解算法的问题。后来我调试了一下,发现是因为在一个事务会话里面MyBatis会自动缓存查询的结果,也就是在它的一级缓存里面会保留一个指向该对象的指针,当我通过插件对查询结果对象中的敏感字段解密并返回给上层业务的时候,MyBatis缓存里面的指针依然指向这个已经解过密的对象。于是,当上层业务再次执行相同的查询方法时,MyBatis直接返回缓存中的这个对象,因为里面的数据都已经解过密了,所以当我的MyBatis插件再次进行解密时,就报错了。三【解决】
要解决这个问题最直接的办法就是通过flushCache="true"或者where <随机数>=<随机数>去除MyBatis的一级缓存,但是这个办法比较极端。其实更好的办法就是在插件里面判断这个对象是否通过缓存获取的,如果不是通过缓存就肯定是加密的,这时就需要解密;如果是通过缓存获取的,就不需要解密了,直接返回就行了,这样也减少了再次解密的性能消耗。
MyBatis提供的插件方案里面没有直接判断查询结果是否从一级缓存中获取的标识。但是我发现org.apache.ibatis.executor.Executor里面 有个boolean isCached(MappedStatement ms, CacheKey key)方法,这个正好可以用来做判断。另外,CacheKey可以通过Executor的createCacheKey方法来生成。
package security.intercepter; import java.lang.reflect.Method; import java.util.List; import java.util.Properties; import org.apache.ibatis.cache.CacheKey; import org.apache.ibatis.executor.Executor; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.plugin.Interceptor; import org.apache.ibatis.plugin.Intercepts; import org.apache.ibatis.plugin.Invocation; import org.apache.ibatis.plugin.Plugin; import org.apache.ibatis.plugin.Signature; import org.apache.ibatis.session.ResultHandler; import org.apache.ibatis.session.RowBounds; @Intercepts({ @Signature( type= Executor.class, method = "update", args = {MappedStatement.class,Object.class}), @Signature( type= Executor.class, method = "query", args = {MappedStatement.class,Object.class, RowBounds.class,ResultHandler.class}) }) public class CryptoInterceptor implements Interceptor{ public Object intercept(Invocation invocation) throws Throwable { final Executor executor = (Executor)invocation.getTarget(); final Method method = (Method) invocation.getMethod(); final MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0]; final Object parameterObject = (Object) invocation.getArgs()[1]; if(method.getName().equals("update")){ /* encrypt security fields of parameterObject */ encrypt(parameterObject); return invocation.proceed(); } else if(method.getName().equals("query")){ final RowBounds rowBounds = (RowBounds) invocation.getArgs()[2]; final BoundSql boundSql = (BoundSql) mappedStatement.getBoundSql(parameterObject); CacheKey cacheKey = executor.createCacheKey(mappedStatement, parameterObject, rowBounds, boundSql); boolean isCached = executor.isCached(mappedStatement, cacheKey); if(isCached) return invocation.proceed(); else{ /* encrypt security fields of parameterObject */ encrypt(parameterObject); List<?> objectList = (List<?>)invocation.proceed(); /* decrypt security fields of element from objectList */ decrypt(objectList); return objectList; } } else{ throw new RuntimeException("unexpected method intercepted: "+method.getName()); } } public Object plugin(Object target) { return Plugin.wrap(target, this); } public void setProperties(Properties properties) { } }
一【场景】
之前系统在运行过程中,老是报一个诡异的死锁检测异常: Error Code: 1213
Deadlock found when trying to get lock; try restartingtransaction。最后仔细研究了一下终于解决了。场景模拟如下:
数据库中2张表:用户表:users,和订单表orders。用户表里面有个字段total用来累计每个用户的订单消费总额,同时orders通过字段user_id与users表做了外键关联。
表users:
CREATE TABLE `users` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(16) NOT NULL DEFAULT '' COMMENT '会员名', `total` decimal(11,2) NOT NULL DEFAULT '0.00' COMMENT '消费总额', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8
表orders:
CREATE TABLE `orders` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `amount` decimal(11,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额', PRIMARY KEY (`id`), KEY `USER_ID` (`user_id`), CONSTRAINT `USER_ID` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8
假设事务A与事务B按照以下序列进行,就会产生死锁,从而引发数据库的死锁检测异常,MySQL就会选择影响行数较少的事务进行回滚:https://dev.mysql.com/doc/refman/5.5/en/innodb-deadlock-detection.html;
序列
事务A
事务B
1
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (1, 10.00);
2
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (1, 25.00);
3
UPDATE users SET total=total+10.00;(发送阻塞)
4
UPDATE users SET total=total+25.00;(产生死锁)
二【分析】
先看死锁日志:
根据2个事务的WAITING FOR THIS LOCK TO BE GRANTED信息可以看出事务A(D68)和事务B(D69)同时在等着给user表中的同一行加X锁,同时D69事务已经获取了这一行的S锁。那么,这儿的问题是这个共享锁是怎么加上的?
后来查看了Mysq的官方文档:https://dev.mysql.com/doc/refman/5.6/en/innodb-foreign-key-constraints.html
就是如果存在外键约束,那么会给这张表的外键关联的表相应的行加上共享锁。那么在我们的这个场景下,就是当insert 2个订单数据的时候,MySQL已经给user表中tom那一行加上了2把共享锁,所以当后面再想着更新tom会员信息的时候,2个事务都在等着对方释放各自的共享锁,于是就产生了死锁。
三【解决】
就目前的这个场景,当然是先更新user表,再插入orders表数据就行了。这样就把user表的S锁直接替换成了X锁,破坏了请求和保持的必要条件,预防了死锁的发生。
不过,现在互联网企业为了方便分表,分库,数据迁移等,已经越来越少的去建立表的外键约束,而是靠上层应用自己去保证了。