Все задачи

На первый-второй-третий-четвертый

10 Feb 2014

В базе данных проекта хранились разные интересные данные. Например:

mysql> show create table movie_titles\G
*************************** 1. row ***************************
       Table: movie_titles
Create Table: CREATE TABLE `movie_titles` (
  `movie_hash` bigint(20) unsigned NOT NULL,
  `title` varchar(255) NOT NULL,
  `count` int(10) unsigned NOT NULL,
  `min_year` int(10) unsigned NOT NULL,
  `max_year` int(10) unsigned NOT NULL,
  `min_budget` int(10) unsigned NOT NULL,
  `max_budget` int(10) unsigned NOT NULL,
  `title_lang_id` varchar(2) NOT NULL DEFAULT '',
  PRIMARY KEY (`movie_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

mysql> select movie_hash, title from movie_titles order by rand() limit 10;
+----------------------+-----------------------------------+
| movie_hash           | title                             |
+----------------------+-----------------------------------+
| 14466895222078503050 | Короткие встречи                  |
| 15971437470478867358 | The Intouchables                  |
| 12235011833542755999 | Карнавальная ночь                 |
|  6981806602143484496 | Ben-Hur                           |
|  5919830798737205613 | Зеркало                           |
|  9358350811894903428 | Ikiru                             |
| 15397825164883494235 | City Lights                       |
|  1081902961608193125 | Я шагаю по Москве                 |
|  6154127428560052859 | The Kid                           |
| 16809407873175270375 | Безымянная звезда                 |
+----------------------+-----------------------------------+
10 rows in set (0.01 sec)

mysql> select movie_hash % 4, count(*) from movie_titles group by 1;
+----------------+----------+
| movie_hash % 4 | count(*) |
+----------------+----------+
|              0 |  1217109 |
|              1 |  1217068 |
|              2 |  1217333 |
|              3 |  1216358 |
+----------------+----------+
4 rows in set (2.78 sec)

Программисту понадобилось произвести над каждой записью в этой таблице некоторое довольно тяжелое вычисление. Для увеличения скорости программист решил запустить обработку в несколько процессов:

#!/usr/bin/perl

use strict;
use warnings;

use DBI;
use Parallel::ForkManager;
use Log::Log4perl qw(:easy);
 
use utf8;
use open ':std' => ':utf8';

Log::Log4perl->easy_init( 
    { 
        level   => $INFO,
        file    => ">>process.log",
        layout => "[%P] %c %d: %m%n",
    } 
);


INFO("START");
my $WORKERS = 4;
my $pm = Parallel::ForkManager->new($WORKERS);

for my $num (0 .. $WORKERS - 1) {
    my $pid = $pm->start and next;
    process_part($num, $WORKERS);
    $pm->finish;
}
$pm->wait_all_children;
INFO("FINISH");
exit(0);

sub process_part
{
    my ($num, $total) = @_;

    INFO("$num/$total - start");

    my $dbh = get_dbh();

    my $data_to_process = $dbh->selectall_arrayref(
        "select movie_hash, title from movie_titles 
        where movie_hash % ? = ?", 
        {},
        $total, $num
    );
    INFO("to process: ".scalar(@$data_to_process));
    for my $record (@$data_to_process) {
        do_some_heavy_processing($record);
    } 
    INFO("$num/$total - finish");
}

sub get_dbh
{
    # ...
    return $dbh;
}

sub do_some_heavy_processing
{
    # ...
}

И все бы хорошо, но в логах видно кое-что странное с распределением работы по воркерам. Что же случилось?

Подсказка

Показать

Лог выглядел примерно так:

[18894] 2014/01/01 20:07:25: START
[18907] 2014/01/01 20:07:25: 0/4 - start
[18908] 2014/01/01 20:07:25: 1/4 - start
[18909] 2014/01/01 20:07:25: 2/4 - start
[18910] 2014/01/01 20:07:25: 3/4 - start
[18908] 2014/01/01 20:07:28: to process: 598
[18910] 2014/01/01 20:07:28: to process: 575
[18909] 2014/01/01 20:07:28: to process: 1208
[18910] 2014/01/01 20:07:30: 3/4 - finish
[18908] 2014/01/01 20:07:30: 1/4 - finish
[18909] 2014/01/01 20:07:31: 2/4 - finish
[18907] 2014/01/01 20:08:34: to process: 4865487
[18907] 2014/01/01 21:13:05: 0/4 - finish
[18894] 2014/01/01 21:13:08: FINISH

Подсказка-2

Показать

То есть почти вся работа досталась нулевому воркеру, а остальные бездельничали.

Подсказка-3

Показать

Программист попробовал поменять количество воркеров. При 8 рабочих процессах почти все записи снова достались нулевому, при 6 – нулевому, второму и четвертому, а между 5 воркерами записи распределились равномерно.

Разоблачение

Показать

Неравномерное распределение записей по рабочим процессам происходит из-за сочетания двух обстоятельств:

  1. При подстановке в запрос через плейсхолдеры (where movie_hash % ? = ?) переменные заключаются в кавычки (получается where movie_hash % '4' = '3').

  2. При выполнении операций над целыми числами, заключенными в кавычки, mysql приводит их к вещественным, и операции производит как с вещественными. А точность вещественных чисел ограничена, и на больших целых ее перестает хватать – младшие биты больших целых, превращенных во float’ы, распределены неравномерно.

Сравните:

mysql> select 17330750813197098651 % 4;
+--------------------------+
| 17330750813197098651 % 4 |
+--------------------------+
|                        3 |
+--------------------------+

mysql> select 17330750813197098651 % '4';
+----------------------------+
| 17330750813197098651 % '4' |
+----------------------------+
|                          0 |
+----------------------------+

mysql> select 17330750813197098651 % 4e0;
+----------------------------+
| 17330750813197098651 % 4e0 |
+----------------------------+
|                          0 |
+----------------------------+

mysql> select 17330750813197098651 % cast('4' as unsigned);
+----------------------------------------------+
| 17330750813197098651 % cast('4' as unsigned) |
+----------------------------------------------+
|                                            3 |
+----------------------------------------------+

Цитата из документации mysql (описывается приведение при сравнении, но при других операциях аналогично):

The following rules describe how conversion occurs for comparison operations:

  • If one or both arguments are NULL … – не наш случай

  • If both arguments in a comparison operation are strings … – не наш случай

  • If both arguments are integers, they are compared as integers. – не наш случай, у нас второй аргумент – строка

  • Hexadecimal values … – не наш случай

  • If one of the arguments is a TIMESTAMP or DATETIME – не наш случай

  • If one of the arguments is a decimal value – не наш случай

  • In all other cases, the arguments are compared as floating-point (real) numbers. – мы попадаем сюда, что подтверждается последним запросом с делением на 6e0.

Чтобы все же обеспечить равномерное распределение работы, программист мог бы воспользоваться либо непосредственной подстановкой переменных в текст запроса (where movie_hash % $total = $num), либо воспользоваться конструкцией cast(? as unsigned).