前言

好了,进入今天的正文,今天想跟大家聊聊一次 mybatis 动态 SQL 引发的生产事故。

事情这样的,我们有个订单相关数据库服务,专门负责订单相关的增删改查。这个服务运行了很久,一直都没有问题。

直到某天中午,正想躺下休息一下,就突然接到系统报警,大量订单创建失败。订单服务可以说是核心服务,这个服务不可用,整个流程都会被卡主,交易都将会失败。

马上没了睡意,立刻起来登上生产运维机,查看订单服务的系统日志。

Caused by: java.util.concurrent.RejectedExecutionException: Thread pool is EXHAUSTED! Thread Name: DubboServerHandler-xxip, Pool Size: 200 (active: 200, core: 200, max: 200, largest: 200), Task: 165633 (completed: 165433), Executor status:(isShutdown:false, isTerminated:false, isTerminating:false), in 1!
       at com.alibaba.dubbo.common.threadpool.support.AbortPolicyWithReport.rejectedExecution(AbortPolicyWithReport.java:53)
       at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:768)
       at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:656)
       at com.alibaba.dubbo.remoting.transport.dispatcher.all.AllChannelHandler.caught(AllChannelHandler.java:65)

如上所示,日志中打印大量的 Dubbo 线程池线程耗尽,直接拒绝服务调用的日志。登上另一台机器,好家伙,除了上述日志以外,仔细翻看居然还发生「OOM」!!!

一条失去条件的动态 SQL,到手的年终奖飞了_java

其实发生 「OOM」 了,问题倒是简单了,首先 「dump」 一下,然后分析一下生成的日志,查找内存占用最大类,然后分析定位具体代码块。

结合系统日志以及 dump 日志,我们很快就定位到发生问题的代码位置,样例代码如下:

Order order=new Order();
log.info("订单查询参数信息:{}",order);
// 其他系统逻辑,关键信息数据加密等
List

查询底层使用 mybatis 动态 sql 功能,样例如下:

<select id="query" parameterType="order" resultMap="orderResultMap">
    select orderId,amt,orderInfo // 还有其他信息
    from
    Order
    <where>
        <if test="orderId != null">
            orderId = #{orderId}
        if>
        <if test="amt != null">
            AND amt = #{amt}
        if>
       ..... 其他条件
    where>
select>

上面的代码很简单,由于传入 mybatis 查询语句参数都未设置,从而导致生成的 sql 缺失了查询条件了,查询全表。

而由于订单表的数据非常多,全表查询返回的数据将会源源不断的加载到应用内存中,从而引发 「Full GC」,导致应用陷入长时间的 「stop the world」

由于 Dubbo 线程也被暂停了,接收到正常的调用无法及时返回结果,从而引发服务消费者超时。

另一方面,由于应用不断接受请求,而大量 Dubbo 线程不能及时处理调用,从而导致 Dubbo 线程池中线程资源被耗尽,后续请求将会被直接拒绝。

最后最后,系统应用内存实在无法再加载任何数据,于是抛出上文中 「OOM」异常。

一条失去条件的动态 SQL,到手的年终奖飞了_java_02这张图真的体现小黑哥当时心态变化

问题本质原因是找到了,那为什么之前查询都没事,而这次突然就没传值了呢?

原来是因为前端页面改动,导致传入的查询参数为空!!!

前端页面迟迟不能显示查询的订单,用户一般会选择重试,然后又未传入查询参数,再一次加重应用的情况,雪上加霜。

一条失去条件的动态 SQL,到手的年终奖飞了_java_03

扩展思考

上面的问题,我们只要重启应用,暂时还是能解决问题。想象一下如果使用动态 sql 发生在其他场景,会怎么样?

假设用户的余额表使用动态 sql 更新,这时如果条件丢失将会导致全部用户的余额都会发生了变化。如果是余额变多,那可能还好。但是如果余额是变少的,那真的很可能演变成社会事故了~

我们再假设下,如果某些配置表使用了动态 sql 物理删除数据,这时如果条件丢失将会导致全表数据被删。数据如果都没了,没什么好说了,跑路吧~

一条失去条件的动态 SQL,到手的年终奖飞了_java_04

可以看到,更新/删除这类动态 sql,如果丢失了条件,那导致的危害将会很大,业务可能都会被停摆。

解决办法

那有没有什么办法解决这些问题?

「很简单,不要用动态 sql 了,直接手写吧~」

一条失去条件的动态 SQL,到手的年终奖飞了_java_05

emm!你们先把刀放下,我开个玩笑的~

虽然上面的问题确实是动态 sql 引起的,但是本质原因我觉得还是使用不当引起的。

我们肯定不能因噎废食,自废武功,从此退回到「刀耕火种」时代,手写 sql。

好了,不说废话了,解决动态 sql 带来潜在的问题,我觉得可以从两方面下手:

第一、改变意识形态,科普动态 sql 可能引发的问题,让所有开发对这个问题引起重视。

只有当我们意识动态 sql 可能引发的问题,我们才有可能在开发过程去思考,这么写会不会被带来问题。

「这一点,我觉得真的很重要。」

第二,针对实际的业务场景提供可控的查询条件,并且对外接口一定要做好必要的参数校验。

我们要从实际的业务场景出发分析对外需要提供那些条件,原则上主库表必须按照主键或唯一键查询单条,或者使用相关的外键查询多条。比如说,订单表查询支付单号这类主键查询。

另外针对这些查询条件,接口层一定要做好的必要的参数校验。如果参数未传,直接打回,防患于未然。

如果真的有需要查询多条数据后台需求,这类查询不需要很高实时性,那么我们其实可以与上面应用查询剥离开来,并且查询使用从库。

第三,增加一些工具类预防插件。

比如我们可以在 mybatis 增加一个插件,检查执行的 sql 是否带有 where 关键字,若不存在直接拦截。

mybatis 拦截器如下:

@Intercepts({
        @Signature(type = Executor.classmethod "query", args = {MappedStatement.classObject.class,
                RowBounds.classResultHandler.class}),
        @Signature(type 
= Executor.classmethod "query", args = {MappedStatement.classObject.class,
                RowBounds.classResultHandler.classCacheKey.classBoundSql.class}),
        @Signature(type 
= Executor.classmethod "update", args = {MappedStatement.class,
                Object.class})})
@Slf4j
public class CheckWhereInterceptor implements Interceptor 
{

    private static final String WHERE = "WHERE";

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //获取方法的第0个参数,也就是MappedStatement。@Signature注解中的args中的顺序
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        //获取sql命令操作类型
        SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
        final Object[] queryArgs = invocation.getArgs();
        final Object parameter = queryArgs[1];
        BoundSql boundSql = mappedStatement.getBoundSql(parameter);
        String sql = boundSql.getSql();
        if (Objects.equals(SqlCommandType.DELETE, sqlCommandType)
                || Objects.equals(SqlCommandType.UPDATE, sqlCommandType)
                || Objects.equals(SqlCommandType.SELECT, sqlCommandType)) {
            //格式化sql
            sql = sql.replace("\n""");
            if (!StringUtils.containsIgnoreCase(sql, WHERE)) {
                sql = sql.replace(" """);
                log.warn("SQL 语句没有where条件,禁止执行,sql为:{}", sql);
                throw new Exception("SQL语句中没有where条件");
            }
        }
        Object result = invocation.proceed();
        return result;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {

    }
}

上面的代码其实还是比较粗糙,各位可以根据各自的业务增加相应的预防措施。

小结

今天的文章,从真实的例子出发,引出了动态 sql 潜在的问题,主要想让大家意识到这方面的问题。从而在今后使用动态 sql 的过程中更加小心。

https://mp.weixin.qq.com/s/NfNxUwr9YiaLddxqpS-IdA