4

一条失去where的动态SQL导致的线上故障

 3 years ago
source link: https://my.oschina.net/u/4579410/blog/4880814
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

关注公众号:后端技术漫谈,技术之路不迷路~





好了,进入今天的正文,今天想跟大家聊聊一次 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」!!!

fa18a8d7-e4f5-4f48-bda4-72aa9537c459.jpg

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

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

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

查询底层使用 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」 异常。

6bae86fc-24c3-4d36-938a-9d1f99db10ad.jpg 这张图真的体现小黑哥当时心态变化

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

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

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

6f58d71e-9f00-4f12-96df-734dd1e12d83.jpg

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

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

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

4d50e73c-4b12-4098-90aa-c8a34d767d97.jpg

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

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

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

0b5c66ea-6b77-47a0-aef7-2a3a430e6bfc.jpg

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

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

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

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

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

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

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

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

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

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

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

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

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

mybatis 拦截器如下:

@Intercepts({
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class,
                RowBounds.class, ResultHandler.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class,
                RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
        @Signature(type = Executor.class, method = "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 的过程中更加小心。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK