写在前面
网上很多的文章都是教科书式的说教,缺乏实用价值。这也是笔者想写此系列文章的初衷,希望把实际工作的实战经验分享给大家,帮助大家解决实际问题。后续的一系列文章都是笔者在实际工作遇到的问题,比较具有代表性,从实战的角度进行分析总结,希望能够给大家带来帮助。
关键字:数据库、Mysql、Mybatis
一、问题背景
用过Mysql的同学,可能都遇到过下面这种情况。比如一张订单表order里面定义了一个varchar的字段,叫做order_id,当我们在表中查询某条记录时,我们使用了如下的方式:
select * from order where order_id = 123456;
显然我们忘记了给123456加上单引号,但是这个查询是有效的,而且查到我们想要的数据。明明order_id是个varchar类型,应该使用order_id = '123456'这种方式明确指定为字符串类型,为什么使用123456这样的数字类型也是ok的呢?这就涉及到了Mysql隐式类型转换的问题,在MySQL中,当操作符与不同类型的操作数一起使用时,会发生类型转换以使操作数相互兼容。
哦!原来这样也可以啊!还省去了写单引号的麻烦,不错不错!如果你平时偷懒在Navicat或者Mysql Console里面用这种方式写几个查询语句也就算了,一旦这样的方式进入到生产环境的代码里面,那你可给自己挖了个大坑哦!
二、问题分析
看问题不能只停留在表面,Mysql这样做确实给平时的操作带来了一些方便之处,但是如果不了解其背后的机理,也给程序带来了不小的隐患。
当然,我们对待知识的态度是要知其然,更要知其所以然。这种问题很容易了解清楚,Mysql参考手册翻出来查一查,立马搞定。以下内容摘自MySQL 5.7 Reference Manual:
The following rules describe how conversion occurs for comparison operations:
If one or both arguments are NULL, the result of the comparison is NULL, except for the NULL-safe <=> equality comparison operator. For NULL <=> NULL, the result is true. No conversion is needed.
If both arguments in a comparison operation are strings, they are compared as strings.
If both arguments are integers, they are compared as integers.
Hexadecimal values are treated as binary strings if not compared to a number.
If one of the arguments is a TIMESTAMP or DATETIME column and the other argument is a constant, the constant is converted to a timestamp before the comparison is performed. This is done to be more ODBC-friendly. This is not done for the arguments to IN(). To be safe, always use complete datetime, date, or time strings when doing comparisons. For example, to achieve best results when using BETWEEN with date or time values, use CAST() to explicitly convert the values to the desired data type.
A single-row subquery from a table or tables is not considered a constant. For example, if a subquery returns an integer to be compared to a DATETIME value, the comparison is done as two integers. The integer is not converted to a temporal value. To compare the operands as DATETIME values, use CAST() to explicitly convert the subquery value to DATETIME.
If one of the arguments is a decimal value, comparison depends on the other argument. The arguments are compared as decimal values if the other argument is a decimal or integer value, or as floating-point values if the other argument is a floating-point value.
In all other cases, the arguments are compared as floating-point (real) numbers.
以上是官方文档中关于隐式转化规则的描述,不翻译,英文功底过硬是对一个码农最基本的要求。主要是笔者翻译水平有限,一翻译就走样。就烦国内有些人翻译的文章,本来人家英文写的通俗易懂、言简意赅,被他们一翻译,狗屁不通、晦涩难懂,还在那里沾沾自喜、自以为是。
关于Mysql类型转换的规则与例子,请查看参考文献1,这里不做过多解释。这里只对最后一条进行一下说明,“所有其他情况下,两个参数都会被转换为浮点数再进行比较”,这条很重要,如果不能深入理解,将会带来很多意想不到的问题发生,切记切记!
有的同学说了,类型转换就转换呗!这不挺方便的吗!其实不然,这种隐式类型转换一来会带来性能问题,二来也存在安全问题。实际上官方文档中已经说的很明白了:
For comparisons of a string column with a number, MySQL cannot use an index on the column to look up the value quickly. If str_col is an indexed string column, the index cannot be used when performing the lookup in the following statement:
SELECT * FROM tbl_name WHERE str_col=1;
The reason for this is that there are many different strings that may convert to the value 1, such as '1', ' 1', or '1a'.
看到了吧!如果你用一个数字与string(varchar)字段进行比较的话,那就无法使用索引了,这可是极大的性能隐患。下面用几个例子直观地说明一下。
还是订单表order里面定义了一个varchar的字段,叫做order_id,并且order_id字段上创建索引idx_order_id,order 表里面大约有14万条数据。
select count(*) from order;
count(*)
142662
规范的使用方式如下,看看它的执行时间。
select * from order where order_id = '219052918283139700160';
0.037s elapsed
那不规范的使用方式呢!
select * from order where order_id = 219052918283139700160;
0.345s elapsed
大约有十倍左右的性能差异。看看执行计划有什么不同,上EXPLAIN神器!
Explain
select * from order where order_id = '219052918283139700160';
id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE order ref idx_order_id idx_order_id 202 const 1 Using index condition
这是规范的使用方式,type为ref,使用了索引idx_order_id,没问题!再看看不规范用法的执行计划。
Explain
select * from order where order_id = 219052918283139700160;
id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE order ALL idx_order_id 135860 Using where
我去!完蛋了!type为All,全表扫描,灾难啊!再看看更详细的内容,使用Explain EXTENDED和SHOW WARNINGS。
Explain EXTENDED
select * from order where order_id = 219052918283139700160;
SHOW WARNINGS;
id select_type table type possible_keys key key_len ref rows filtered Extra
1 SIMPLE order ALL idx_order_id 135864 100.00 Using where
Level Code Message
Warning 1739 Cannot use ref access on index 'idx_order_id' due to type or collation conversion on field 'order_id'
Warning 1739 Cannot use range access on index 'idx_order_id' due to type or collation conversion on field 'order_id'
Note 1003 /* select#1 */ select `xx`.`order`.`id` AS `id`,`xx`.`order`.`is_delete` AS `is_delete`,`xx`.`order`.`create_time` AS `create_time`,`xx`.`order`.`update_time` AS `update_time`,`xx`.`order`.`order_id` AS `order_id`,`xx`.`order`.`req_param` AS `req_param`,`xx`.`order`.`source` AS `source` from `xx`.`order` where (`xx`.`order`.`order_id` = 219052918283139700160)
进一步证明无法使用索引。
另外,对于隐式类型转换存在的安全性问题,主要有两方面:一是容易被sql注入攻击、二是查询/删除/更新时会错误命中多余数据。
先说sql注入的情况,例如下面这条语句:
SELECT * FROM users WHERE username = '?' AND password = '?';
如果password输入的是a' OR 1='1,那么username随便输入,这样就生成了下面的查询:
SELECT * FROM users WHERE username = 'xxx' AND password = 'a' OR 1='1';
由于or的优先级最低,and次之,所以这条语句其实等同于:
SELECT * FROM users WHERE (username = 'xxx' AND password = 'a') OR 1='1';
结果1='1'为true。
接着说第二种情况,继续看下面的例子:
mysql> select * from test;
+----+-------+-----------+
| id | name | password |
+----+-------+-----------+
| 1 | test1 | password1 |
| 2 | test2 | password2 |
| 3 | 12 | ddd |
| 4 | 12a | bbb |
+----+-------+-----------+
6 rows in set (0.00 sec)
mysql> select * from test where name = 12;
+----+-------+----------+
| id | name | password |
+----+-------+----------+
| 3 | 12 | ddd |
| 4 | 12a | bbb |
+----+-------+----------+
2 rows in set, 5 warnings (0.00 sec)
mysql> select * from test where name = '12';
+----+------+----------+
| id | name | password |
+----+------+----------+
| 3 | 12 | ddd |
+----+------+----------+
1 row in set (0.00 sec)
本意是查询id为3的那一条记录,结果把id为4的那一条也查询出来了,删除和更新操作也存在同样的问题。
三、使用建议
其实使用建议也很简单朴实,避免隐式类型转换,是什么类型就用什么类型的条件。字段是数值类型,就用同样的数值类型比较;字段是varchar,那就别图省事,一定要叫上单引号。
实际上在我之前的一篇文章(参考文献3)中提到过隐式自动转换可能存在的问题,但没有展开讨论。再回想到上篇文章中提到的My batis的两种传参方式${}与#{},#{}这种方式能够很大程度的防止sql注入,而${}则无法防止sql注入。想当黑客,还是得了解很多技术细节的。
四、参考文献
- MySQL 5.7 Reference Manual - https://dev.mysql.com/doc/refman/5.7/en/type-conversion.html
- MySQL隐式转化整理 - https://www.cnblogs.com/rollenholt/p/5442825.html
- 实战系列:(二)一条被Mybatis误导的sql语句 - https://www.jianshu.com/p/21802f7118b6
2019年7月10日 星期三 于北京至唐山途中