• MySQL 主从时间字段相差1秒深度揭密


    1. 主从数据异常

             某实例一个datetime字段主从数据不一致,其它数据暂未发现异常。第一反应,有可能是人为修改,如果有用户有高权限帐号,是可能做到这事的,检查所有帐号权限排除了这种可能。难道有黑客入侵?神经一下绷紧,仔细排查各种系统状态,很快也排除了这种可能。同时分析业务类型,有问题的值都是从机都是比主少一秒,时间戳被改小一秒不能带来任何收益,被非法篡改的可能性基本排除。

    2. 初步分析

             对比数据发现,从机比主机少一秒的数据经常出现,但从机主从复制状态一直正常,主机binlog中未发现有语句被跳过。对比master上binlog及slave上relaylog, relaylog和master上binlog完全一致,有问题的数据在master上binlog里就已经少一秒了,因此肯定从机状态是正常的,问题出在master上。         继续分析发现有问题的数据,发现在master上select出来和binlog中就是差一秒,很是诡异邪门。打开general_log,记录一段时间操作,抓到了大量的时间相差一秒的情况行的前后插入语句,观察发现general_log 和binlog中时间相同,但直接select出来的值多一秒,没有其它多余操作,完全排除非法篡改的情况。至此可以认定,异常数据在innodb引擎中存的数据和binlog中的数据是不一致的,在这里需要说明的是master上binlog格式是mixed,非row格式并不能完全保证binlog数据可靠。          继续分析geneal_log,有问题的行是都是通过Prepared Execute方式插入的,尝试联系前端人员复现问题未果后,自己想办法复现。分析问题数据有几个特点:

    1. Prepared Execute 方式插入
    2. 部分数据差一秒,非全部
    3. 有问题的数据在binlog中都是比innodb中的少一秒
    4. datetime字段未指定精度 由于前端大多是通过jdbc方式访问数据库,于是根据上面特征在本地构造jdbc用例操作库,绑定变量,构造prepare execute方式插入数据,当设置预编译模式(useServerPrepStmts=true)复现了bug,复现的java代码如下:
    1. import java.sql.*;
    2. import java.text.SimpleDateFormat;
    3. public class insertData {
    4. public static void main(String[] args)
    5. {
    6. String driver = "com.mysql.jdbc.Driver";
    7. String url = "jdbc:mysql://127.0.0.1:3306/mydb?useServerPrepStmts=true";
    8. String user = "u1";
    9. String password = "123456";
    10. try
    11. {
    12. Class.forName(driver);
    13. Connection conn = DriverManager.getConnection(url, user, password);
    14. if(!conn.isClosed())
    15. System.out.println("Succeeded connecting to the Database!");
    16. //String dateStr = "2018-09-25 14:36:45.666";
    17. //SimpleDateFormat form = new SimpleDateFormat ("yyyy-MM-dd HH:mm:ss.SSS");
    18. //java.util.Date date=form.parse(dateStr);
    19. //java.sql.Timestamp tmp= new java.sql.Timestamp(date.getTime());
    20. //System.out.println(tmp);
    21. long time = System.currentTimeMillis();
    22. Date date = new Date(time);
    23. java.sql.Timestamp tmp= new java.sql.Timestamp(date.getTime());
    24. System.out.println(tmp);
    25. //create table test(a datetime);
    26. PreparedStatement pstmt = conn.prepareStatement("insert into test values(?)");
    27. pstmt.setTimestamp(1, tmp);
    28. pstmt.execute();
    29. conn.close();
    30. }
    31. catch(ClassNotFoundException e)
    32. {
    33. System.out.println("Sorry,can`t find the Driver!");
    34. e.printStackTrace();
    35. }
    36. catch(SQLException e)
    37. {
    38. e.printStackTrace();
    39. } catch(Exception e)
    40. {
    41. e.printStackTrace();
    42. }
    43. }
    44. }

             用例很简单,取当前时间插入数据库,binlog中有不少时间值少一秒,如下图: 

     

    第一个图是java插入数据并打印出原始时间数据,第二个图是数据库中binlog及直接查出的时间数据,显然秒以下精度大于0.5秒的两个时间出现了相差一秒的情况,至此可以初步认定该问题是由于精度引起,如果前端将秒以下精度清零再插入,不会有这问题。

    3. 深度挖掘

    一、前端参数简介

            到此问题似乎已经解决,前端精度清零即可,但是这只是临时方案,为什么精度不清零会有问题?根本原因是什么?精度问题前端页面和入库有不一致可以接受,但是在入库后binlog与innodb存的数据不一致,没有任何提示报错直接导致主从数据不一致,这是不能接受的,必须挖出根本原因,保证数据的可靠性。         当用其它方式操作库没有问题,用prepare不用预编译模式(useServerPrepStmts=false)也不会有问题,由此可见触发该问题的关键是是否使用预编译模式。

    Jdbc对prepare的大致处理过程如下: useServerPrepStmts=true时Jdbc对prepare的处理 1) 创建PreparedStatement对象,向服务器发送COM_PREPARE命令,并传送带问号的sql. 服务器返回jdbc stmt->id等信息 2) 向服务器发送COM_EXECUTE命令,并只传送参数信息。 useServerPrepStmts=false时Jdbc对prepare的处理 1) 创建PreparedStatement对象,此时不会和服务器交互。 2) 根据参数和PreparedStatement对象拼接完整的SQL,向服务器发送QUERY命令 当使用预编译时prepare阶段发带问号语句到后端,执行时只发参数,相同语句后端只需要解析一次。非预编译时,prepare时与后端无交互,执行时拼接完整的SQL发到后端运行,即问号已经在前端被填充好值,每条语句都要解析一次。          预编译模式,执行时只发参数,引擎层把传过来的参数值处理落盘,而写入binlog的语句是把参数转换拼接而成。这里引擎层落盘数据和sql层转换拼接还原binlog语句是两个独立的过程,可以提出一个假设:如果sql层和引擎层对精度处理逻辑不一致,innodb引擎层对秒以下做四舍五入,而sql层直接舍弃秒以下精度,那么就可能造成这个问题。

    二、binlog语句来处

            带着上面的假设分析源码,尝试从源码中继续寻找答案。先找binlog的语句来处,mysql中sql语句都保存在thd->query_string中,但这里的query_string中的参数是问号,它是在函数insert_params_with_log中拼接还原而成, 把时间值转成了字串符填入了sql中的问号,如下:

    1. 937 if (query->replace(param->pos_in_query+length, 1, *res))
    2. (gdb) bt
    3. #0 insert_params_with_log (stmt=0x7f77e8c56280, null_array=0x7f77f7f6600a "", read_pos=0x7f77f7f6601a "", data_end=0x7f77f7f6601a "", query=0x7f7879e29f00) at /data/new56/event/DB/sql/sql_prepare.cc:937
    4. #1 0x000000000081eb0c in Prepared_statement::set_parameters (this=0x7f77e8c56280, expanded_query=0x7f7879e29f00, packet=0x7f77f7f6600e "\v\342\a\v\002\n\005\065\370\334\f", packet_end=0x7f77f7f6601a "")
    5. at /data/new56/event/DB/sql/sql_prepare.cc:3594
    6. #2 0x000000000081ebeb in Prepared_statement::execute_loop (this=0x7f77e8c56280, expanded_query=0x7f7879e29f00, open_cursor=false, packet=0x7f77f7f6600a "", packet_end=0x7f77f7f6601a "")
    7. at /data/new56/event/DB/sql/sql_prepare.cc:3655
    8. #3 0x000000000081c845 in mysqld_stmt_execute (thd=0x7f77f7f62000, packet_arg=0x7f77f7f66001 "\001", packet_length=25) at /data/new56/event/DB/sql/sql_prepare.cc:2701
    9. #4 0x00000000007f972a in dispatch_command (command=COM_STMT_EXECUTE, thd=0x7f77f7f62000, packet=0x7f77f7f66001 "\001", packet_length=25) at /data/new56/event/DB/sql/sql_parse.cc:1351
    10. #5 0x00000000007f8931 in do_command (thd=0x7f77f7f62000) at /data/new56/event/DB/sql/sql_parse.cc:1060
    11. #6 0x00000000007be7d1 in do_handle_one_connection (thd_arg=0x7f77f7f62000) at /data/new56/event/DB/sql/sql_connect.cc:998
    12. #7 0x00000000007be2ab in handle_one_connection (arg=0x7f77f7f62000) at /data/new56/event/DB/sql/sql_connect.cc:905
    13. #8 0x0000000000b2f665 in pfs_spawn_thread (arg=0x7f7877ffd920) at /data/new56/event/DB/storage/perfschema/pfs.cc:1860
    14. #9 0x00007f7879b6d9d1 in start_thread () from /lib64/libpthread.so.0
    15. #10 0x00007f78788d68fd in clone () from /lib64/libc.so.6
    16. (gdb) p *query
    17. $1 = {Ptr = 0x7f77e8c15040 "insert into test values(?)", str_length = 26, Alloced_length = 32, alloced = true, str_charset = 0x182f260}
    18. (gdb) n
    19. 940 length+= res->length()-1;
    20. (gdb) p *query
    21. $2 = {Ptr = 0x7f77e8c1c070 "insert into test values('2018-11-02 10:45:17')", str_length = 46, Alloced_length = 48, alloced = true, str_charset = 0x182f260}

            可以看到,在937行把问号做了替换,还原了sql语句,而问号里的值在上面 param->set_param_func函数中获取,set_param_func在此实际调用的是set_param_datetime,代码如下:

    1. static void set_param_datetime(Item_param *param, uchar **pos, ulong len)
    2. {
    3. MYSQL_TIME tm;
    4. ulong length= get_param_length(pos, len);//前端发过来的参数长度
    5. if (length >= 4)
    6. {//下面把前端发过来的参数转成MYSQL_TIME时间
    7. uchar *to= *pos;
    8. tm.neg= 0;
    9. /*
    10. 前端发过来时间格式数字内存转的字符串:20181101125510124566,代表的时间为20181101125510124566微秒
    11. 分别用2字节存年,月日时分秒均用1个字节存,微秒4字节,一共2+1+1+1+1+1+4=11字节,前端发过来的时间
    12. 格式就是按这个方式把数据堆在一起,下面的逻辑就是把时间还原出来
    13. */
    14. tm.year= (uint) sint2korr(to);
    15. /*
    16. sint2korr(A) (int16) (((int16) ((uchar) (A)[0])) + ((int16) ((int16) (A)[1]) << 8))
    17. 把两个字节所表示的整数还原出来,下面sint4korr把四字节表示的整数还原出来
    18. */
    19. tm.month= (uint) to[2]; //0~255之间值一个字节可以存放
    20. tm.day= (uint) to[3];
    21. if (length > 4)
    22. {
    23. tm.hour= (uint) to[4];
    24. tm.minute= (uint) to[5];
    25. tm.second= (uint) to[6];
    26. }
    27. else
    28. tm.hour= tm.minute= tm.second= 0;
    29. tm.second_part= (length > 7) ? (ulong) sint4korr(to+7) : 0;
    30. }
    31. else
    32. set_zero_time(&tm, MYSQL_TIMESTAMP_DATETIME);
    33. /*
    34. 此时打印时间是带精度的:
    35. (gdb) p tm
    36. $9 = {year = 2018, month = 11, day = 2, hour = 10, minute = 45, second = 17, second_part = 908000, neg = 0 '\000', time_type = 32632}
    37. */
    38. param->set_time(&tm, MYSQL_TIMESTAMP_DATETIME,
    39. MAX_DATETIME_FULL_WIDTH * MY_CHARSET_BIN_MB_MAXLEN);
    40. *pos+= length;
    41. }

            至此,在set_param_datetime中把前端传过来的时间参数转成了tm时间,并在param->set_time设置到了value中,param->set_time函数如下:

    1. void Item_param::set_time(MYSQL_TIME *tm, timestamp_type time_type,
    2. uint32 max_length_arg)
    3. {
    4. DBUG_ENTER("Item_param::set_time");
    5. value.time= *tm; //保存时间
    6. value.time.time_type= time_type;
    7. if (check_datetime_range(&value.time))
    8. {
    9. make_truncated_value_warning(ErrConvString(&value.time,
    10. MY_MIN(decimals,
    11. DATETIME_MAX_DECIMALS)),
    12. time_type);
    13. set_zero_time(&value.time, MYSQL_TIMESTAMP_ERROR);
    14. }
    15. state= TIME_VALUE;
    16. maybe_null= 0;
    17. max_length= max_length_arg;
    18. decimals= 0; //精度设为0,不管原来值是多少,一律清0,关键点=================
    19. DBUG_VOID_RETURN;
    20. }

            再回到 insert_params_with_log函数,在上面的937行上面两行调用 param->query_val_str 函数把tm时间转成了时间值字符串:

    1. const String *Item_param::query_val_str(THD *thd, String* str) const
    2. {
    3. switch (state) { //state值为TIME_VALUE
    4. case INT_VALUE:
    5. str->set_int(value.integer, unsigned_flag, &my_charset_bin);
    6. break;
    7. case REAL_VALUE:
    8. str->set_real(value.real, NOT_FIXED_DEC, &my_charset_bin);
    9. break;
    10. case DECIMAL_VALUE:
    11. if (my_decimal2string(E_DEC_FATAL_ERROR, &decimal_value,
    12. 0, 0, 0, str) > 1)
    13. return &my_null_string;
    14. break;
    15. case TIME_VALUE://==========>进入
    16. {
    17. char *buf, *ptr;
    18. str->length(0);
    19. /*
    20. TODO: in case of error we need to notify replication
    21. that binary log contains wrong statement
    22. */
    23. if (str->reserve(MAX_DATE_STRING_REP_LENGTH+3))
    24. break;
    25. /* Create date string inplace */
    26. buf= str->c_ptr_quick();
    27. ptr= buf;
    28. *ptr++= '\'';
    29. /*
    30. 这里decimals在前面set_time中被设置成了0,MY_MIN(decimals, DATETIME_MAX_DECIMALS)为
    31. 取小值,因此 my_TIME_to_str(&value.time, ptr,MY_MIN(decimals, DATETIME_MAX_DECIMALS))
    32. 等价于my_TIME_to_str(&value.time, ptr,0) ,传入的精度位数为0,my_TIME_to_str函数是把时间
    33. 转成字符串,如果精度为0直接忽略秒以下值
    34. */
    35. ptr+= (uint) my_TIME_to_str(&value.time, ptr,
    36. MY_MIN(decimals, DATETIME_MAX_DECIMALS));
    37. *ptr++= '\'';
    38. str->length((uint32) (ptr - buf));
    39. break;
    40. }
    41. case STRING_VALUE:
    42. case LONG_DATA_VALUE:
    43. {
    44. str->length(0);
    45. append_query_string(thd, value.cs_info.character_set_client, &str_value,
    46. str);
    47. break;
    48. }
    49. case NULL_VALUE:
    50. return &my_null_string;
    51. default:
    52. DBUG_ASSERT(0);
    53. }
    54. return str;
    55. }

            在my_TIME_to_str中又是直接调用my_datetime_to_str把时间转成的字符串逻辑如下:

    1. //l_time 待转的时间
    2. //to 存转换后的时间字符串
    3. //dec 精度
    4. int my_datetime_to_str(const MYSQL_TIME *l_time, char *to, uint dec)
    5. {
    6. int len= TIME_to_datetime_str(to, l_time); //TIME_to_datetime_str仅转换年月日时分秒
    7. if (dec) //精度不为0则进入把精度转换处理
    8. len+= my_useconds_to_str(to + len, l_time->second_part, dec);
    9. else //精度为0则直接舍弃秒以下精度。前面已经将精度置为0,这种场景下,每次都走这个else逻辑分支,直接忽略秒以下精度
    10. to[len]= '\0';
    11. return len;
    12. }

            整个逻辑可以非常清楚的看到,sql层在处理这种情况传过来的时间参数时,直接把时间的秒以下精度丢掉生成一个时间字符串,给binlog和general log还原问号生成sql语句,因此binlog中的sql语句时间是直接忽略精度,非四舍五入的。

    三、InnoDB引擎时间去处

            上面已经证明了猜测的一半,sql层还原语句直接丢弃了秒以下的精度。而从结果上来看,innodb层应该是做了四舍五入,究竟是不是这么做的呢?可以继续跟踪。在row_insert_for_mysql函数打断点,往回追踪,最终是在函数 my_datetime_round中处理的时间,如果没有指定精度,会根据传过来的实际参数值是否有秒以下精度来做四舍五入,大于0.5秒的会向前取整加一秒,有兴趣的同学可以跟踪看一下,这里不再细述。

    总结

            真实原因和猜测的一样,sql层和引擎层处理精度逻辑不一样,一个直接舍弃,一个四舍五入,造成了相差一秒的情况,有的时候,合理的假设往往能做到有的放矢,避免乱碰乱撞,达到事半功倍的效果。知道了根本原因要修复也比较简单,即可以都舍去,也可以都四舍五入,逻辑保持一致即可。下载最新版官方代码复现时发现官方已经修复了这个问题,5.6 & 6.7都已经修复,都使用了四舍五入的逻辑,因此用户可以使用新版本避免这个问题。当然如果不想手工改代码修复,又不想升级最新版本,以下几个方法也可以避免这个问题:

    1. 前端时间类型秒以下精度清0,前端取到的时间是有微秒精度的,清零之后再发到后台。
    2. 使用binlog用row模式。
    3. 不用预解编译定变量模式(useServerPrepStmts=false)。

    后续

    mysql 官方5.7.18、5.6.36 修复了该bug。附官方commit说明:

    1. SHA-1: 6b3d07f3343a1fe7039cfc5fb8b6da092ccde793
    2. * BUG#25187670: BACKPORT BUG#19894382 TO 5.6 AND 5.7.
    3. Backport from mysql-trunk to mysql-5.6 and mysql-5.7.
    4. Bug#19894382 SERVER SIDE PREPARED STATEMENTS LEADS TO POTENTIAL
    5. OFF-BY-SECOND TIMESTAMP ON SLAVE
    6. For temporal type input parameter (as time, timestamp and
    7. datetime) of server side prepared statement, the fractional
    8. second part is ignored while preparing the query string.
    9. Executing such query string at slave is resulting in a time
    10. value difference between master and server.
    11. For server prepared statement, the parameters passed for
    12. execution are parsed and Item_param objects are created for
    13. it. While preparing Item_param for temporal types time,
    14. timestamp and datetime, precision for fractional second part's
    15. (Item_param::decimals) is set to "0" always. Because of which
    16. while preparing query string with param values, fractional second
    17. value is ignored. So execution of such prepared statements uses
    18. correct values but query string formed is incorrect. Hence
    19. difference in temporal type value is observed on usage of query
    20. string.
    21. Fix:
    22. --------
    23. If temporal types time, timestamp and datetime has fractional
    24. second part then setting microseconds (6 digit) precision for
    25. the Item_param.
  • 相关阅读:
    面向Java开发者的ChatGPT提示词工程(4)
    【HackTheBox】Fawn
    机器学习随笔(1)——pandas.DataFrame和数据清洗
    测开 | Vue速查知识点
    Java基础简单整理
    CUDA工程的CMakeLists.txt
    Yolov7代码解析
    编写函数判断密码的有效性
    telnet测试smtp
    Java分布式系统和云计算教程
  • 原文地址:https://blog.csdn.net/postgres20/article/details/127650224