1.业务发生的问题
项目最近出了一次线上事故,是因为 我们底层使用的Tkmybatis, 在SQL层查询参数为空, 导致没有做拦截异常, 查询全表的数据,拉取了400W的数据, 导致内存OOM, 很不应该发生的一次事故, 今天我们来讲一下 问题及解决方案
错误代码如下
事件发生有几个必要因素
- 1. 租户隔离做的不严谨,应该用唯一标识 xxx 作为租户的隔离依据
- 2. 代码层不规范, userId 业务层没有判空 异常判断, 流转到SQL层
- 3.Example 用法不规范, 错误 Example example = new Example(UserInfoPO.class, true, true); 没有做入参字段及必填校验
今天我们从几个方面改进 来纠正这种错误, 实现租户隔离
2. 最不建议的方法
最容易想到的也是改动最大的方法, 只要在manager层 做下拦截判空, 不要透传到底层SQL查询
这种方法不建议使用, 代码改动也是最多的,代码入侵较多, 需要对每一个Manager的 需要userId的方法 都加一行, 进行拦截
参数错误 抛出异常即可, 写一个工具类 避免了再次出现这种问题
public class ParamCheckUtil {
/**
* 校验参数是否有值
*/
public static void checkInputField(String... fields) {
if (null == fields || fields.length == 0) {
throw new XrxsException(PARAM_NOT_VALID, "SQL查询参数异常");
}
if (StringUtil.isAnyBlank(fields)) {
throw new XrxsException(PARAM_NOT_VALID, "SQL查询参数异常");
}
}
}
3. Example 使用
不严谨的写法
Example example = new Example(UserInfoPO.class);
正确的规范写法 , 加上 两个参数
Example example = new Example(UserInfoPO.class, true, true);
- 第二个参数 表示 exists 字段存在校验, 比如 你传入的是 userId, 单词字段拼错写成了大写 userID 这种都不能识别, 找不到字段信息, 抛出错误
- 第三个参数 标识 notNull 字段不为空校验, 这就可以优先的避免 userId 状况出现, 如果入参为空, 都会进行拦截非空校验, 抛出错误
使用规范的用例, 可以有效的避免这种问题, 校验入参的非空, 但是notNull无法校验 in 参数中的 List 空列表, 这点写代码的时候要注意
4.Interceptor拦截器实现SQL注入
这也是我们今天要讲的主要内容, 一个个做校验 太麻烦, 我们可以统一用拦截器来做, 为每一个SQL查询都注入 userId的信息, 保证SQL层查询的租户隔离
Mybatis提供了一个入口,可以让你在语句执行过程中的某一点进行拦截调用。我们需要实现Interceptor接口, 默认情况下,MyBatis 允许使用插件来拦截的方法调用包括以下四个对象的方法:
-
Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed) 执行器
- MyBatis 执行器,是 MyBatis 调度的核心,负责 SQL 语句的生成和查询缓存的维护
-
ParameterHandler (getParameterObject, setParameters) 参数处理器
- 负责对用户传递的参数转换成 JDBC Statement 所需要的参数
-
ResultSetHandler (handleResultSets, handleOutputParameters) 结果处理器
- 负责将 JDBC 返回的 ResultSet 结果集对象转换成 List 类型的集合;
-
StatementHandler (prepare, parameterize, batch, update, query) 流程处理器
- 封装了 JDBC Statement 操作,负责对 JDBC statement 的操作,如设置参数、将 Statement 结果集转换成 List 集合
这四种处理器, 分别对应我们CRUD的几种操作
- StatementHandler、ParameterHandler 两种查询(单个/列表查询)、新增、修改、删除 5个方法都会执行
- Executor、StatementHandler、ParameterHandler、ResultSetHandler 两种查询(单个/列表查询) 都会执行
所以我们需要针对特定的场景来 定义不同的执行器:
- 如果想改入参,可以在ParameterHandler里面获取到入参 , 然后包装处理, 再执行;
- 如果想改sql语句,可以在StatementHandler(prepare)里面获取到connection修改;
- 如果是sql查询的语句, 也可以在Executor的query方法中获取connection修改;
- 如果想对结果数据进行处理,比如脱敏,替换填充, 可以在ResultSetHandler的进行脱敏处理;
根据我们的需求, 我们是要改SQL语句, 添加填充参数信息, 所以放在 StatementHandler prepare中处理, 我们分为几个步骤
- ExampleHeadInterceptor 拦截 StatementHandler 过程处理器, prepare 方法
- 针对拦截的mapper方法的 类进行处理, 判断哪些类需要拦截处理, 哪些放行 , 有些业务方法可能不需要userId
- 再获取执行的SQL, metaObject.getValue("sql"); 解析完毕, 拼接参数 userId , 修改SQL 语句
- 把拦截器 注入 SqlSessionFactoryBean , 设置拦截器执行顺序 Interceptor[] plugins = new Interceptor[]{onePlugin};
DataSourceConfig.java DB配置, 要定制拦截器顺序, 注入SqlSessionFactoryBean
@Autowired
private MySelfInterceptor mySelfPlugin;
@Bean
public SqlSessionFactoryBean sqlSession() {
SqlSessionFactoryBean sqlSession = new SqlSessionFactoryBean();
sqlSession.setDataSource(dataSource());
//自定义拦截器
Interceptor[] plugins = new Interceptor[]{mySelfPlugin};
sqlSession.setPlugins(plugins);
try {
Resource[] resources = new PathMatchingResourcePatternResolver().getResources("classpath:sqlmapper/*.xml");
sqlSession.setMapperLocations(resources);
return sqlSession;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
ExampleHeadInterceptor 拦截器实现
package com.jzj.tdmybatis.config.interceptor;
import com.jzj.tdmybatis.config.AutoBaseMapper;
import org.apache.ibatis.executor.statement.RoutingStatementHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
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.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.util.Properties;
import lombok.extern.slf4j.Slf4j;
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}),
})
@Component
@Slf4j
public class MySelfInterceptor implements Interceptor {
public Object intercept(Invocation invocation) throws Throwable {
// 拼装我们需要添加的 SQL信息 ,我们也可以从 threadLocal 中获取
String myAddSql = " address = '北京' and ";
//这里改sql,但是如果是对select的sql语句进行修改,建议实现Executor.class的plugin中进行,当前方式改select语句insert/update/delete都会走这个判断
StatementHandler statementHandler = (RoutingStatementHandler) invocation.getTarget();
//获取 MappedStatement信息
MetaObject classMetaObject = MetaObject.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY, new DefaultReflectorFactory());
MappedStatement mappedStatement = (MappedStatement) classMetaObject.getValue("delegate.mappedStatement");
//找到 类.方法(), mapId:com.jzj.tdmybatis.repository.mapper.UserInfoMapper.selectByExample_COUNT
String mapperId = mappedStatement.getId();
//取类信息
int splitLocation = mapperId.lastIndexOf(".");
String className = mapperId.substring(0, splitLocation);
Class clazz = Class.forName(className);
MetaObject metaObject = SystemMetaObject.forObject(statementHandler.getBoundSql());
// 获取SQL信息 SELECT count(0) FROM user_info WHERE ((is_del = ?))
String execSql = (String) metaObject.getValue("sql");
// //拦截select 查询方法
if ((execSql.startsWith("select ") || execSql.startsWith("SELECT "))) {
//如果是查询请求, 切分where 拼装 userId信息
StringBuffer sb = new StringBuffer();
if (execSql.contains("where ") || execSql.contains("WHERE ")) {
//只要包含where 的 语句, 全都拼上 需要的id信息
String[] sqlWheres = execSql.split("where|WHERE");
for (int i = 0; i < sqlWheres.length; i++) {
if (i == 0) {
//第一个不需要
sb.append(sqlWheres[i]);
continue;
}
String sql = sqlWheres[i];
sb.append(" where ").append(myAddSql);
sb.append(sql);
}
}
metaObject.setValue("sql", sb.toString());
}
Object returnObject = invocation.proceed();
return returnObject;
}
@Override
public Object plugin(Object target) {
//我们可以借助Plugin的wrap方法来使用当前的拦截器包装我们的目标对象
Object wrap = Plugin.wrap(target, this);
//返回的就是为当前target创建的动态代理
return wrap;
}
public void setProperties(Properties properties) {
}
}
测试结果
我们测试的是 注入的SQL是 添加地址查询条件 address = '北京'
执行SQL 如下 ,没有任何地址信息
@Override
public List<UserInfoPO> queryAllUser() {
Example example = new Example(UserInfoPO.class, true, true);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("isDel", 0);
return mapper.selectByExample(example);
}
查询结果如下, 可以看到 SQL 结果,已经被拦截器 统一注入了 address = '北京' , 查询结果 的条件
查询结果, 符合预期
2023-05-08 21:16:40.438 INFO 26129 --- [nio-8800-exec-1] c.j.tdmybatis.controller.TestController :
[{"id":1,"userId":"556","userName":"aa","age":1,"address":"北京","orderIds":"[1, 2, 3]","goods":"{"deptId": 1, "deptName": "部门1", "deptLeaderId": 4}","sortOrder":0,"isDel":0,"addtime":0,"modtime":0},
{"id":6,"userId":"66","userName":"ff","age":34,"address":"北京","orderIds":"[10, 5, 6]","goods":"{"deptId": 2, "deptName": "部门2", "deptLeaderId": 4}","sortOrder":0,"isDel":0,"addtime":0,"modtime":0},
{"id":12,"userId":"5896","userName":"ll","age":14,"address":"北京","orderIds":"[28, 35, 36]","goods":"{"deptId": 4, "deptName": "部门4", "deptLeaderId": 4}","sortOrder":0,"isDel":0,"addtime":0,"modtime":0}]
类似我们可以 注入 user_id = xx 的条件 , 来实现 无代码入侵的 统一拦截器来处理 租户隔离