共计 2875 个字符,预计需要花费 8 分钟才能阅读完成。
一、反常的 SQL 语句
某周四午休时候,我正在工位上小憩,睡梦中好像看到了本人拿着李白在光荣峡谷里大杀四方的情景,就在我刚拿完五杀筹备率领队友推对面水晶的时候,一句慌乱短促的“糟了”把我从睡梦中惊醒。
我眯开朦胧的双眼,才发现方才的发声来源于我的组长庄哥,看到他在缓和的点开日志零碎查看日志,我预感到有什么不妙的事件产生,认真一问才晓得,原来就在我眯眼的期间,线上数据库服务器的 CPU 被打满,同时触发了生产数据库只读提早的限定工夫并且收回告警,而且告警的过程继续了半个小时。
这让我倒吸了一口凉气,因为咱们组做的零碎很多都用的是同一个数据库服务器,日用户沉闷量有好几十万,如果服务器解体了将会使所有的零碎服务都不可用,于是咱们连忙通过 SQL 日志进行问题查找,最初排查进去是 因为一张 SQL 的高量查问没有走索引导致,日志列表显示,这条 sql 语句的扫描行数达到了上百万,根本就是全表扫描的状况,而且半个小时的工夫查问了达上万次,每条 SQL 查问的耗时都在 3000ms 以上。
我的天啊,难怪服务器会 CPU 打满,这么一条耗时的 SQL 语句查问量这么大,数据库的资源当然是间接就解体了,这是过后那条 SQL 的查问状况:
二、长期解决
看了这条语句,我又倒吸一口凉气,这不就是我写的零碎调用的 SQL 语句吗?完了,这回逃不掉了,真是人在睡梦里,锅从天上来。
当然,因为是我本人写的 SQL,所以我一看就晓得这条语句是有问题的。
依据我的代码解决,这条 sql 的调用还少了个重要的参数 user\_fruit\_id,这个参数没有传的话是不应该走这条 sql 查问的,在我的设计里,该参数是数据表里一个联结索引的最左侧字段,如果该字段没有传值的话,那么索引就不会失效了。
KEY \`idx\_userfruitid\_type\` (
\`user\_fruit\_id\`,
\`task\_type\`,
\`receive\_start\_time\`,
\`receive\_end\_time\`
) USING BTREE
尽管定位到了 SQL 语句,然而线上的问题迫不及待,总不可能找出 bug 改完再上线吧,所以,咱们只能做了一个长期解决,就是在原来的表上多加了一个联结索引,其实就是去掉了 user\_fruit\_id 字段,让这些高量的查问都能走新的索引,就像上面这样:
KEY \`idx\_task\_type\_receive\_start\_time\` (
\`task\_type\`,
\`receive\_start\_time\`,
\`receive\_end\_time\`,
\`created\_time\`
) USING BTREE
加上索引后,sql 的扫描行数就大幅度的升高了,重启实例后就又能失常运行了。
三、最左匹配准则
那么为什么最左侧的字段没传索引就不失效了,这是因为 MySQL 的联结索引是基于“最左匹配准则”匹配的。
咱们都晓得,索引的底层是 B + 树结构,联结索引的构造也是 B + 树,只不过键值数量不是一个,而是多个,构建一颗 B + 树只能依据一个值来构建,因而数据库根据联结索引最左的字段来构建 B + 树。
例如咱们用两个字段(name,age)这个联结索引来剖析,
当咱们在 where 条件中查找 name 为“张三”的所有记录的时候,能够疾速定位到 ID4,并且查出所有蕴含“张三”的记录,而如果要查找“张三,10”这一条特定的数据,就能够用 name = “ 张三 ” and age = 10 获取。
因为联结索引的键值对是两个,所以只有后面的 name 确定的状况下就能够进一步定位到具体的 age 记录,然而如果你的查问条件只有 age 的话,那么索引就不会失效,因为没有匹配最右边的字段,前面所有的索引字段都不会失效,所以我之前写的 sql 语句才会因为少了最右边的 user\_fruit\_id 字段而走了全表扫描的查问形式。
失常来说,假如一个联结索引设计成 (a,b) 这样的构造的话,那么用 a and b 作为条件,或者 a 独自作为查问条件都会走索引,这种状况下咱们就不要再为 a 字段独自设计索引了。
但如果查问条件外面只有 b 的语句,是无奈应用 (a,b) 这个联结索引的,这时候你不得不保护另外一个索引,也就是说你须要同时保护(a,b)、(b) 这两个索引。
四、找出 Bug
尽管长期做了解决,但问题并不算解决,很显著是零碎呈现了 bug 才会有走这样的查问条件。
因为是我本人写的代码,所以晓得是哪条 SQL 后我就马上定位到了代码里的具体方法,起初才发现是因为我对 user\_fruit\_id 字段的判空解决不失效所致。
因为该字段是从调用方传过来的,所以我在办法参数里对该字段做了非空限度的注解,也就是 javax 包下的 @NotNull。
public class GardenUserTaskListReq implements Serializable {
private static final long serialVersionUID = -9161295541482297498L;
@ApiModelProperty(notes = "水果 id")
@NotNull(message = "水果 id 不能为空")
private Long userFruitId;
/\*\* 以下省略 \*/
.....................
}
尽管加上该注解来做非空校验,但我却没有在参数加上另一个注解@Validated,该注解如果没加上的话,那么调用 javax 包下的校验规定就都不失效,正确的写法是在 controller 层办法的参数后面加上注解:
除此之外,因为 user\_fruit\_id 这个字段是另一张表的主键,我在代码里也没有对这张表是否存在这个 id 做查问判断,这样一来,无论调用方传什么值过去都会间接触发 sql 查问,并且在不跑索引的状况下间接走全表扫描。
不得不说,这真是个低级谬误,说真的,我对这个起因真是感到嘀笑皆非,再怎么说也工作几年了,怎么还犯一些老手级别的谬误呢,这脸打得真是让我相当羞愧。
五、总结
尽管是低级谬误,但造成的结果也算挺重大了,这次事件也让我更加的警醒,在当前的开发工作中必须要恪守该有的准则,大略有这么几点:
1、不能置信调用端
重要的参数都要先做验证,即便是非空值也须要做验证,不符合条件的就要间接返回或抛异样,不能参加业务 sql 的查问,否则频繁的拜访也会对服务造成累赘。
2、SQL 语句要先做性能查问
对于数据量大的表,建好索引后,所有的 SQL 查问语句要用 explain 检测性能,并且依据后果来进一步优化索引。
3、代码必须要 review
之前我没有放太大的精力在代码的 review 上,虽说跟迭代排期的紧凑也有关系,但不管怎么说,bug 的确是我的忽略造成的,尤其是像空值这种细小的谬误在 Java 里能够说粗茶淡饭。
千里之堤毁于蚁穴,有时一个小 bug 很容易就引发整个零碎的崩盘,这一次的问题也让我更加粗浅的意识到了 review 代码的重要性,不论业务开发的工作量有多麻烦,这一步操作相对不能漠视。