高精度时间:如何在MySQL和PHP中工作几分之一秒


一旦我想到,在数据库中使用时间时,我几乎总是使用精确到秒的时间,这仅仅是因为我已经习惯了,并且这是文档中描述的选项以及大量示例。 但是,现在这种精度远远不能满足所有任务的需要。 现代系统很复杂-它们可以由许多部分组成,有数以百万计的用户与它们交互-并且在许多情况下,使用更高的精度更为方便,而这种精度已经存在了很长时间。


在本文中,我将讨论在MySQL和PHP中使用时间的几分之一秒的方法。 它被设计为教程,因此该材料是为广泛的读者设计的,在某些地方重复了文档。 主要的价值应该在于,我在一个文本中收集了在MySQL,PHP和Yii框架中使用这些时间所需的一切知识,并且还添加了对您可能遇到的显而易见问题的描述。


我将使用术语“高精度时间”。 在MySQL文档中,您将看到术语“小数秒”,但是其字面翻译听起来很奇怪,但是我没有找到其他确定的翻译。


什么时候使用高精度时间?


首先,我将显示收件箱中收件箱的屏幕截图,它很好地说明了这个想法:


一位发件人发送的两封信


信件是同一个人对一个事件的反应。 一个人不小心按下了错误的按钮,迅速意识到了这一点并纠正了自己。 结果,我们收到了大约同时发送的两个字母,这对于正确排序很重要。 如果发送时间相同,则可能会以错误的顺序显示字母,使收件人感到尴尬,因为这样他会收到错误的结果,他将为此计算。


我遇到了以下情况,其中高精度时间是很重要的:


  1. 您要测量某些操作之间的时间。 这里的一切都非常简单:时间间隔边界处的时间戳精度越高,结果的精度越高。 如果您使用整秒,那么您可能会犯错误1秒钟(如果您落在秒的边界上)。 如果使用六个小数位,则误差将降低六个数量级。
  2. 您有一个集合,其中可能有多个具有相同创建时间的对象。 一个例子是大家都熟悉的聊天,其中联系人列表按上一条消息的时间排序。 如果出现逐页导航,则甚至存在在页面边界失去联系的风险。 由于通过一对字段(时间+对象的唯一标识符)进行排序和分页,可以在没有高精度时间的情况下解决此问题,但是此解决方案具有其缺点(至少是SQL查询的复杂性,不仅如此)。 增加时间的准确性将有助于减少出现问题的可能性,并且不会使系统复杂化。
  3. 您需要保留某些对象的更改历史记录。 这在服务世界中尤其重要,在服务世界中,修改可以并行进行,也可以在完全不同的地方进行。 例如,我可以处理我们用户的照片,其中可以并行执行许多不同的操作(用户可以将照片设为私人照片或将其删除,可以在多个系统之一中对其进行审核,裁剪后用作聊天中的照片等)。 )

必须牢记,一个人不能100%信任所获得的值,并且所获得的值的实际准确性可能少于六个小数位。 这是由于以下事实:我们获得的时间值不准确(特别是在由许多服务器组成的分布式系统中工作时),时间可能会意外更改(例如,通过NTP同步或更改时钟时)等。我不会讨论所有这些问题,但是我将提供一些文章,您可以在其中阅读更多有关它们的信息:



在MySQL中使用高精度时间


MySQL支持三种可以存储时间的列: TIMEDATETIMETIMESTAMP 。 最初,它们只能存储一秒的倍数的值(例如,2019-08-14 19:20:21)。 在2011年12月发布的5.6.4版本中,可以使用零点几秒的时间。 为此,在创建列时,需要指定小数位数,该位数必须存储在时间戳的小数部分中。 支持的最大字符数为六个,这使您可以存储精确到微秒的时间。 如果尝试使用更多字符,则会出现错误。


一个例子:


 Test> CREATE TABLE `ChatContactsList` ( `chat_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY, `title` varchar(255) NOT NULL, `last_message_send_time` timestamp(2) NULL DEFAULT NULL ) ENGINE=InnoDB; Query OK, 0 rows affected (0.02 sec) Test> ALTER TABLE `ChatContactsList` MODIFY last_message_send_time TIMESTAMP(9) NOT NULL; ERROR 1426 (42000): Too-big precision 9 specified for 'last_message_send_time'. Maximum is 6. Test> ALTER TABLE `ChatContactsList` MODIFY last_message_send_time TIMESTAMP(3) NOT NULL; Query OK, 0 rows affected (0.09 sec) Records: 0 Duplicates: 0 Warnings: 0 Test> INSERT INTO ChatContactsList (title, last_message_send_time) VALUES ('Chat #1', NOW()); Query OK, 1 row affected (0.03 sec) Test> SELECT * FROM ChatContactsList; +---------+---------+-------------------------+ | chat_id | title | last_message_send_time | +---------+---------+-------------------------+ | 1 | Chat #1 | 2019-09-22 22:23:15.000 | +---------+---------+-------------------------+ 1 row in set (0.00 sec) 

在此示例中,插入记录的时间戳具有零分数。 发生这种情况是因为输入值显示为最接近的秒数。 要解决该问题,输入值的精度必须与数据库中的值相同。 该建议似乎很明显,但却是相关的,因为在实际应用中可能会出现类似的问题:我们面临这样的情况,即输入值的小数位数为3位,数据库中存储了6位。


防止此问题发生的最简单方法是使用输入精度最高的输入值(最高微秒)。 在这种情况下,将数据写入表时,时间将四舍五入到所需的精度。 这是绝对正常的情况,不会引起任何警告:


 Test> UPDATE ChatContactsList SET last_message_send_time="2019-09-22 22:23:15.2345" WHERE chat_id=1; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 Test> SELECT * FROM ChatContactsList; +---------+---------+-------------------------+ | chat_id | title | last_message_send_time | +---------+---------+-------------------------+ | 1 | Chat #1 | 2019-09-22 22:23:15.235 | +---------+---------+-------------------------+ 1 row in set (0.00 sec) 

当使用格式为DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP的结构对TIMESTAMP列进行自动初始化和自动更新时,重要的是,这些值必须与列本身具有相同的精度:


 Test> ALTER TABLE ChatContactsList ADD COLUMN updated TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP; ERROR 1067 (42000): Invalid default value for 'updated' Test> ALTER TABLE ChatContactsList ADD COLUMN updated TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6); ERROR 1067 (42000): Invalid default value for 'updated' Test> ALTER TABLE ChatContactsList ADD COLUMN updated TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3); Query OK, 0 rows affected (0.07 sec) Records: 0 Duplicates: 0 Warnings: 0 Test> UPDATE ChatContactsList SET last_message_send_time='2019-09-22 22:22:22' WHERE chat_id=1; Query OK, 0 rows affected (0.00 sec) Rows matched: 1 Changed: 0 Warnings: 0 Test> SELECT * FROM ChatContactsList; +---------+---------+-------------------------+-------------------------+ | chat_id | title | last_message_send_time | updated | +---------+---------+-------------------------+-------------------------+ | 1 | Chat #1 | 2019-09-22 22:22:22.000 | 2019-09-22 22:26:39.968 | +---------+---------+-------------------------+-------------------------+ 1 row in set (0.00 sec) 

随着时间的推移,MySQL函数支持使用度量单位的小数部分。 我不会列出所有内容(我建议您在文档中查找),但是我将给出一些示例:


 Test> SELECT NOW(2), NOW(4), NOW(4) + INTERVAL 7.5 SECOND; +------------------------+--------------------------+------------------------------+ | NOW(2) | NOW(4) | NOW(4) + INTERVAL 7.5 SECOND | +------------------------+--------------------------+------------------------------+ | 2019-09-22 21:12:23.31 | 2019-09-22 21:12:23.3194 | 2019-09-22 21:12:30.8194 | +------------------------+--------------------------+------------------------------+ 1 row in set (0.00 sec) Test> SELECT SUBTIME(CURRENT_TIME(6), CURRENT_TIME(3)), CURRENT_TIME(6), CURRENT_TIME(3); +-------------------------------------------+-----------------+-----------------+ | SUBTIME(CURRENT_TIME(6), CURRENT_TIME(3)) | CURRENT_TIME(6) | CURRENT_TIME(3) | +-------------------------------------------+-----------------+-----------------+ | 00:00:00.000712 | 21:12:50.793712 | 21:12:50.793 | +-------------------------------------------+-----------------+-----------------+ 1 row in set (0.00 sec) 

在SQL查询中使用秒的小数部分相关的主要问题是比较( ><BETWEEN )的准确性不一致。 如果数据库中的数据具有一种准确性,而查询中的具有另一种准确性,则可能会遇到这种情况。 这是一个说明此问题的小示例:


 #         Test> INSERT INTO ChatContactsList (title, last_message_send_time) VALUES ('Chat #2', '2019-09-22 21:16:39.123456'); Query OK, 0 row affected (0.00 sec) Test> SELECT chat_id, title, last_message_send_time FROM ChatContactsList WHERE title='Chat #2'; +---------+---------+-------------------------+ | chat_id | title | last_message_send_time | +---------+---------+-------------------------+ | 2 | Chat #2 | 2019-09-22 21:16:39.123 | <-     - ,    +---------+---------+-------------------------+ 1 row in set (0.00 sec) Test> SELECT title, last_message_send_time FROM ChatContactsList WHERE last_message_send_time >= '2019-09-22 21:16:39.123456'; <-    ,    INSERT- +---------+-------------------------+ | title | last_message_send_time | +---------+-------------------------+ | Chat #1 | 2019-09-22 22:22:22.000 | +---------+-------------------------+ 1 row in set (0.00 sec) <- Chat #2   - ,     ,     

在此示例中,查询中的值的准确性高于数据库中的值的准确性,并且此问题发生在“从上方的边界上”。 在相反的情况下(如果输入值的精度低于数据库中的值)将没有问题-MySQL将在INSERT和SELECT中将值都提高到所需的精度:


 Test> INSERT INTO ChatContactsList (title, last_message_send_time) VALUES ('Chat #3', '2019-09-03 21:20:19.1'); Query OK, 1 row affected (0.00 sec) Test> SELECT title, last_message_send_time FROM ChatContactsList WHERE last_message_send_time <= '2019-09-03 21:20:19.1'; +---------+-------------------------+ | title | last_message_send_time | +---------+-------------------------+ | Chat #3 | 2019-09-03 21:20:19.100 | +---------+-------------------------+ 1 row in set (0.00 sec) 

在使用高精度时间时,应始终牢记值准确性的一致性。 如果此类边界问题对您来说很关键,那么您需要确保代码和数据库使用相同的小数位数。


关于选择小数秒部分的列中的精度的思考

时间单位的小数部分占用的空间量取决于列中的字符数。 选择熟悉的含义似乎很自然:三到六个小数位。 但是,对于三个字符,这并不是那么简单。 实际上,MySQL使用一个字节存储两个小数位:


分数秒精度储存要求
00字节
一二1个字节
3 42字节
5、63个字节


日期和时间类型存储要求

事实证明,如果选择三个小数位,那么您将无法充分利用占用的空间,并且对于相同的开销,您可能需要四个字符。 通常,我建议您在输出时始终使用偶数个字符,并在必要时“裁剪”不必要的字符。 理想的选择是不要贪婪,并保持小数点后六位。 在最坏的情况下(具有DATETIME类型),此列将占用8个字节,即与BIGINT列中的整数相同。


另请参阅:



在PHP中使用高精度时间


在数据库中拥有高精度时间是不够的-您需要能够在程序代码中使用它。 在本节中,我将讨论三个要点:


  1. 接收和格式化时间:我将解释如何在将时间戳记放入数据库,从那里获取并进行某种操作之前获取时间戳。
  2. 在PDO中使用时间:我将向您展示一个示例,说明PHP如何支持数据库库中的时间格式化。
  3. 在框架中使用时间:我将讨论在迁移中使用时间来更改数据库的结构。

获取和格式化时间


在处理时间时,您需要执行一些基本操作:


  • 获取当前时间点;
  • 从格式化的字符串中获取时间;
  • 在时间点上添加一个周期(或减去一个周期);
  • 获取某个时间点的格式化字符串。

在这一部分中,我将告诉您PHP中执行这些操作的可能性。


第一种方法是使用时间戳作为数字 。 在这种情况下,在PHP代码中,我们使用数值变量,这些变量通过诸如timedatestrtotime函数进行操作。 此方法不能用于高精度时间,因为在所有这些函数中,时间戳均为整数(这意味着时间戳中的小数部分将丢失)。


以下是官方文档中主要此类功能的签名:


time ( void ) : int
https://www.php.net/manual/ru/function.time.php

strtotime ( string $time [, int $now = time() ] ) : int
http://php.net/manual/ru/function.strtotime.php

date ( string $format [, int $timestamp = time() ] ) : string
https://php.net/manual/ru/function.date.php

strftime ( string $format [, int $timestamp = time() ] ) : string
https://www.php.net/manual/ru/function.strftime.php

关于日期函数的有趣之处

尽管不可能将秒的小数部分传递给这些函数的输入,但是在传递给date函数的输入的格式模板行中,您可以将字符设置为显示毫秒和微秒。 格式化时,零将始终返回其位置。


字符串格式的字符内容描述返回值示例
ü微秒(在PHP 5.2.2中添加)。 请注意,date()将始终返回000000,因为 它使用一个整数参数,而DateTime :: format()如果与它们一起创建DateTime,则支持微秒。例如:654321
v毫秒(PHP 7.0.0中已添加)。 备注与您相同。例如:654

一个例子:


 $now = time(); print date('Ymd H:i:s.u', $now); // 2019-09-11 21:27:18.000000 print date('Ymd H:i:s.v', $now); // 2019-09-11 21:27:18.000 

此方法还包括microtimehrtime ,这些hrtime使您可以获得当前时刻的带有小数部分的hrtime 。 问题在于尚无现成的方式来格式化这样的标签并从特定格式的字符串中获取它。 可以通过独立实现这些功能来解决,但我不会考虑这种选择。


如果您只需要使用计时器,则HRTime库是一个不错的选择,由于使用限制,我将不作详细介绍。 我只能说,它使您可以工作到十亿分之一秒的时间,并保证了计时器的单调性,从而消除了使用其他库时可能遇到的一些问题。

要完全使用秒的小数部分,您需要使用DateTime模块。 进行某些保留后,您可以执行上面列出的所有操作:


 //    : $time = new \DateTimeImmutable(); //      : $time = new \DateTimeImmutable('2019-09-12 21:32:43.908502'); $time = \DateTimeImmutable::createFromFormat('Ymd H:i:s.u', '2019-09-12 21:32:43.9085'); // / : $period = \DateInterval::createFromDateString('5 seconds'); $timeBefore = $time->add($period); $timeAfter = $time->sub($period); //      : print $time->format('Ymd H:i:s.v'); // '2019-09-12 21:32:43.908' print $time->format("Ymd H:i:su"); // '2019-09-12 21:32:43.908502' 

使用`DateTimeImmutable :: createFromFormat`时的非明显点

格式字符串中的字母u表示微秒,但是在精度较低的小数部分的情况下也可以正常工作。 此外,这是在格式字符串中指定秒的小数部分的唯一方法。 一个例子:


 $time = \DateTimeImmutable::createFromFormat('Ymd H:i:s.u', '2019-09-12 21:32:43.9085'); // =>   DateTimeImmutable    2019-09-12 21:32:43.908500 $time = \DateTimeImmutable::createFromFormat('Ymd H:i:s.u', '2019-09-12 21:32:43.90'); // =>   DateTimeImmutable    2019-09-12 21:32:43.900000 $time = \DateTimeImmutable::createFromFormat('Ymd H:i:s.u', '2019-09-12 21:32:43'); // =>  false 

该模块的主要问题是在处理包含小数秒的时间间隔时会带来不便(甚至无法进行此类工作)。 \DateInterval尽管它包含精确到小数点后六位的秒的小数部分\DateInterval但是您只能通过DateTime::diff初始化此小数部分。 DateInterval类的构造函数和工厂方法\DateInterval::createFromDateString仅可以整秒使用,并且不允许指定小数部分:


 //     -   $buggyPeriod1 = new \DateInterval('PT7.500S'); //       ,    $buggyPeriod2 = \DateInterval::createFromDateString('2 minutes 7.5 seconds'); print $buggyPeriod2->format('%R%H:%I:%S.%F') . PHP_EOL; //  "+00:02:00.000000" 

使用\DateTimeImmutable::diff方法计算两个时间点之间的差异时,可能会出现另一个问题。 在7.2.12版之前的PHP中,存在一个错误,由于该错误 ,一秒钟的小数部分与其他数字分开存在,并且可能会收到自己的符号:


 $timeBefore = new \DateTimeImmutable('2019-09-12 21:20:19.987654'); $timeAfter = new \DateTimeImmutable('2019-09-14 12:13:14.123456'); $diff = $timeBefore->diff($timeAfter); print $diff->format('%R%a days %H:%I:%S.%F') . PHP_EOL; //  PHP  7.2.12+   "+1 days 14:52:54.135802" //       "+1 days 14:52:55.-864198" 

通常,我建议您在使用间隔时要格外小心,并在测试中仔细覆盖此类代码。


另请参阅:



在PDO中以高精度时间工作


PDO和mysqli是从PHP代码查询MySQL数据库的两个主要接口。 在有关时间的对话中,它们彼此相似,因此我只谈论其中之一-PDO。


在PDO中使用数据库时,时间出现在两个地方:


  • 作为传递给已执行查询的参数;
  • 作为响应SELECT查询的值。

将参数传递给请求时,最好使用占位符。 占位符可以从很小的一组类型中传输值:布尔值,字符串和整数。 没有合适的日期和时间类型,因此您必须手动将值从DateTime / DateTimeImmutable类的对象转换为字符串。


 $now = new \DateTimeImmutable(); $db = new \PDO('mysql:...', 'user', 'password', [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); $stmt = $db->prepare('INSERT INTO Test.ChatContactsList (title, last_message_send_time) VALUES (:title, :date)'); $result = $stmt->execute([':title' => "Test #1", ':date' => $now->format('Ymd H:i:s.u')]); 

使用这样的代码不是很方便,因为每次您都需要格式化传输的值。 因此,在Badoo代码库中,我们在包装程序中实现了对类型化占位符的支持,以使用数据库。 对于日期而言,这非常方便,因为它允许您以不同的格式传输值(实现DateTimeInterface的对象,带格式的字符串或带有时间戳的数字),并且所有必需的转换和对传输值的正确性的检查都已在内部完成。 另外,当传递不正确的值时,我们会立即了解该错误,而不是在执行查询时从MySQL收到错误后才了解该错误。


从查询结果中检索数据看起来非常简单。 执行此操作时,PDO以字符串形式返回数据,并且在代码中,如果我们要使用时间对象,我们需要进一步处理结果(这里我们需要从格式化的字符串中获取时间的功能,这在上一节中已经讨论过)。


 $stmt = $db->prepare('SELECT * FROM Test.ChatContactsList ORDER BY last_message_send_time DESC, chat_id DESC LIMIT 5'); $stmt->execute(); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $row['last_message_send_time'] = is_null($row['last_message_send_time']) ? null : new \DateTimeImmutable($row['last_message_send_time']); //  -  } 

注意事项

PDO将数据作为字符串返回的事实并不完全正确。 接收值时,可以使用PDOStatement::bindColumn设置列的值类型。 我之所以没有谈论这一点,是因为存在相同的有限类型集,这些类型对日期没有帮助。

不幸的是,有一个问题要注意。 在版本7.3之前的PHP中,存在一个错误,由于该错误,如果PDO::ATTR_EMULATE_PREPARESPDO::ATTR_EMULATE_PREPARES属性,则当从数据库接收到PDO::ATTR_EMULATE_PREPARES属性时PDO::ATTR_EMULATE_PREPARES “切断”该秒的小数部分。 可以在php.net上错误描述中找到详细信息和示例。 在PHP 7.3中,此错误已修复,并警告该更改会破坏向后兼容性


如果您使用的是PHP 7.2或更早版本,并且无法更新或启用PDO::ATTR_EMULATE_PREPARES ,则可以通过更正返回小数部分的返回时间的SQL查询来解决此错误,以便此列具有字符串类型。 例如,可以这样做:


 SELECT *, CAST(last_message_send_time AS CHAR) AS last_message_send_time_fixed FROM ChatContactsList ORDER BY last_message_send_time DESC LIMIT 1; 

使用mysqli模块时也会遇到此问题:如果通过调用mysqli::prepare方法使用mysqli准备查询,则在7.3版之前的PHP中,将不返回小数部分。 与PDO一样,您可以通过更新PHP或绕过将时间转换为字符串类型来解决此问题。


另请参阅:



高精度工作在Yii 2


大多数现代框架都提供了迁移功能,使您可以在代码中存储数据库架构更改的历史记录并进行增量更改。 如果您使用迁移并希望使用高精度时间,那么您的框架应该支持它。 幸运的是,这在所有主要框架中都是开箱即用的。


在本节中,我将展示如何在Yii中实现这种支持(在示例中,我使用的是2.0.26版)。 关于Laravel,Symfony和其他人,我不会写这篇文章以使文章永无止境,但是如果您在有关此主题的评论或新文章中添加详细信息,我将感到高兴。


在迁移中,我们编写描述数据模式更改的代码。 创建新表时,我们使用\ yii \ db \ Migration类中的特殊方法来描述其所有列(它们在SchemaBuilderTrait托盘中声明)。 可以采用准确性输入值的timetimestampdatetime方法负责描述包含日期和时间的列。


一个迁移示例,其中使用高精度时间列创建新表:


 use yii\db\Migration; class m190914_141123_create_news_table extends Migration { public function up() { $this->createTable('news', [ 'id' => $this->primaryKey(), 'title' => $this->string()->notNull(), 'content' => $this->text(), 'published' => $this->timestamp(6), //     ]); } public function down() { $this->dropTable('news'); } } 

这是一个迁移示例,其中现有列的准确性发生了变化:


 class m190916_045702_change_news_time_precision extends Migration { public function up() { $this->alterColumn( 'news', 'published', $this->timestamp(6) ); return true; } public function down() { $this->alterColumn( 'news', 'published', $this->timestamp(3) ); return true; } } 

ActiveRecord - : , DateTime-. , — «» PDO::ATTR_EMULATE_PREPARES . Yii , . , , PDO.


. :



结论


, , — , . , , . , !

Source: https://habr.com/ru/post/zh-CN469615/


All Articles