一【問題】
因爲有些敏感字段是必須加密存儲的,爲了不讓數據安全存儲的要求影響正常的業務邏輯,就寫了個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鎖,破壞了請求和保持的必要條件,預防了死鎖的發生。
不過,現在互聯網企業爲了方便分表,分庫,數據遷移等,已經越來越少的去建立表的外鍵約束,而是靠上層應用自己去保證了。