Приветствую! У каждого разработчика рано или поздно наступает момент, когда появляется необходимость работать с большими базами данных. В мире таблиц весом более 5 гигабайт действуют немного иные законы "физики", нежели в маленьких табличках: приходится заботиться о тех вещах, о которых раньше даже и не задумывался. Сегодня я поделюсь трюком, который поможет быстро удалить много данных с таблицы MySQL с движком InnoDB.

Проблема

У нас на руках имеется: Таблица с сообщениями `messages` за последние 10 дней. Каждый день в неё сваливается примерно миллион новых записей. По прошествии 10-ти дней у нас скрипт вызываемый кроном перетаскивает сообщения в таблицу `messages_history`. Нам поставили задачу уменьшить срок хранения сообщений в основной таблице с 10 дней до трёх.

Решение "В лоб"

//Собираем все сообщения
$oldMessages = Message::findAll(new DBWhereParamsType([
  'where' => [
    'time <=' => Carbon::now()->subDays(self::LIFETIME_OF_MESSAGES)->format(),
  ],
]));

//Собираем ID всех сообщений, которые достали
$oldMessagesIDs = [];
foreach ($oldMessages as $oldMessage) {
  $oldMessagesIDs[] = $oldMessage->id;
}

//Вставляем полученные старые сообщения в таблицу с историей сообщений
$res = MessageHistory::bathInsert($oldMessages);

//Если insert прошёл успешно, удаляем из основной таблицы старые сообщения
if ($res === true) {
      
  //Эта конструкция под капотом собирает запрос вида
  // DELETE * FROM `messages` WHERE `id` IN (1, 2, 3...)
  MessageD::deleteWhere(new DBWhereParamsType([
    'where' => [
        'id' => $idsList,
      ],
  ]));
}

Вроде бы всё логично. Запускаем с лимитом 50000, и что же мы видим?

Результаты метода "В лоб"
Результаты метода "В лоб"

Удаление заняло более минуты. Но почему же так? Давайте разбираться

Блокировка таблиц

При удалении из таблицы InnoDB происходит её блокировка на время выполнения транзакции. Когда мы посылаем большой запрос на удаление базе, она блокирует таблицу `messages`, и это довольно сильно затягивает процесс, так как при каждом обращении других скриптов к этой таблице будет создаваться её слепок.

Что с этим делать?

DELETE - это один из немногих случаев, когда оптимизация должна идти не на уменьшение количества запросов, а наоборот, на их увеличение и разбивку на маленькие. И поможет нам в этом php функция array_chunk. Она разобьёт наш $oldMessagesIDs на чанки, по которым будет удобно пройтись foreach'ом:

if ($res === true) {
  //Разбиваем массив на чанки
  $oldMessagesIDsChunks = array_chunk($oldMessagesIDs, 100);

  //Проходимся по каждому из них
  foreach ($oldMessagesIDsChunks as $idsList) {
    MessageD::deleteWhere(new DBWhereParamsType([
      'where' => [
        'id' => $idsList,
      ],
    ]));
  }
}

Запускаем с лимитом 50000...

Результат разбиения на чанки
Результат разбиения на чанки

Вуаля! Итак, давайте подытожим, что у нас случилось:

  • Таблица не блокируется одной транзакцией на долгое время

  • Среднее время выполнения скрипта уменьшилось примерно в 5 раз

Надеюсь, этот небольшой трюк поможет Вам однажды настроить скрипт удаляющий большие объёмы данных :-) Удачи!

Комментарии (43)


  1. akamap
    17.11.2022 12:56
    +1

    в deleteWhere нельзя указать то же условие, что и в findAll ?


    1. lexspeedkb Автор
      17.11.2022 13:21
      -3

      В данном случае нет, так как сообщения постоянно добавляются в БД, и вполне может быть ситуация, когда в это условие добавятся новые вхождения за те миллисекунды, пока происходила обработка данных. Да, именно в примере с сообщениями это вряд ли возможно, но это всего лишь один пример)


      1. Asper1
        17.11.2022 20:46
        +3

        Используйте в фильтрах фиксированное время для обработки сообщений, тогда новые сообщения никак не будут влиять на обработку.


        1. michael_v89
          18.11.2022 18:09

          Будут, если новое сообщение пришло в ту же секунду, что и последнее, полученное в SELECT, но после выполнения SELECT.
          Зачем использовать фиксированное возрастающее время для идентификации записей, если можно использовать фиксированный возрастающий идентификатор записи?


          1. baldr
            18.11.2022 18:54

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

            В один прекрасный день придет сисадмин, посмотрит что INTEGER скоро переполнится и сделает ALTER TABLE tablename AUTO_INCREMENT = 1


            1. Akina
              18.11.2022 19:48

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

              Это если Вы говорите о программной генерации идентификатора или об идентификаторе типа UUID(). Если же речь идёт об автоинкрементном генераторе, то он обязан гарантировать соблюдение двух правил-условий, а именно то, что следующее сгенерированное им для поля значение будет (1) больше предыдущего сгенерированного (2) больше максимального в этом поле.

              Допустимость несоблюдения любого из этих условий немедленно приводит к потенции генерации уже существующего в таблице значения и, соответственно, к возникновению ошибки дублирования. Некоторые СУБД вообще блокируют прямое присвоение значения автоинкрементному полю.

              Кстати, из этих же требований проистекает и то, что "дыры" в значении автоинкремента для двух последовательно сгенерированных значений - штатное событие. Ибо неразрывность - не гарантируется.

              Но отчасти Вы правы. Порядок генерации значений и порядок их записи в таблицу (точнее, порядок моментов обращения к генератору и порядок моментов выполнения фиксации транзакций) имеют право не совпадать.

              В один прекрасный день придет сисадмин, посмотрит что INTEGER скоро переполнится и сделает ALTER TABLE tablename AUTO_INCREMENT = 1

              Если автоинкремент обрабатывается правильно, то введённое админом значение будет скорректировано (увеличено), чтобы выполнялось правило 2..


              1. baldr
                19.11.2022 01:03

                Вот, кстати, не факт что у автора статьи в базе id был целочисленный автоинкремент, а не тот же uuid. В таком случае бессмыссленно здесь спорить о том что ему нужно по "id < X" фильтровать. Впрочем, сдается мне что там int, все-таки.

                немедленно приводит к потенции генерации уже существующего в таблице значения и, соответственно, к возникновению ошибки дублирования.

                Верно. Поменял вручную - можешь получить по рукам (потом, при вставке).

                Некоторые СУБД вообще блокируют прямое присвоение значения автоинкрементному полю.

                Это какие, например? MySQL, MSSQL, Postgres, Oracle - все имеют штатный механизм для (ручного) сброса счетчика.

                Что касается автоинкремента - конкретно он, да - является частным случаем генератора уникального идентификатора и, видимо, расти будет только вверх. Я точно помню что давно читал что-то про внутренние механизмы БД по использованию пустых промежутков для автогенерации primary key, но не могу найти это нигде и уже начинаю сомневаться в том что это имеет смысл.

                В ANSI SQL вроде как есть определение счетчика, который может "зацикливаться", с конструкцией вроде такого:

                id integer GENERATED ALWAYS AS IDENTITY (start with 42 increment by 10 cycle)

                Но такой синтаксис, видимо, не поддерживается MySQL. Статья по ссылке выше немного тяжеловата для чтения, я нашел ссылку на нее на SO - там чуть понятнее.

                Если автоинкремент обрабатывается правильно, то введённое админом значение будет скорректировано (увеличено), чтобы выполнялось правило 2..

                Сейчас лень проверять, но, по-моему, при попытке вставить строку в таблицу в PostgreSQL, если в таблице уже есть строка с таким уникальным id, который предлагает sequence, то база выругается на ошибку, строка вставлена не будет, но счетчик у sequence увеличится.


            1. michael_v89
              18.11.2022 21:00

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

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


              1. Akina
                18.11.2022 22:30

                В статье описано удаление по списку id, независимо от порядка.

                Статья в качестве СУБД использует MySQL. Который вполне допускает прямое присвоение в автоинкрементное поле, в том числе значение менее текущего атрибута таблицы, если оно "свободно". Если вставить такую запись во время выполнения процесса очистки - код, ориентирующийся на значение id по диапазону, эту запись смахнёт в корзину и не почешется. Чтобы этого не произошло, код должен "вытянуть" на себя все значения, подлежащие удалению, и потом возвращать их в запросе на удаление очередного чанка.


                1. michael_v89
                  18.11.2022 22:36

                  код, ориентирующийся на значение id по диапазону

                  Но в статье нет удаления по диапазону id, как это связано с темой обсуждения?


                  код должен "вытянуть" на себя все значения, подлежащие удалению, и потом возвращать их в запросе на удаление очередного чанка

                  Ну так в статье так и делается, а человек говорит, что вместо этого надо использовать время в запросе на удаление.


  1. baldr
    17.11.2022 13:24
    +9

    А где транзакция-то? Это где такой код пишут?

    Я правильно понял что вы засасываете в память из базы десяток миллионов записей, причем в виде ORM-обьектов, а потом вставляете их в другую таблицу в той же базе? Ничего тут оптимизировать нельзя? Подсказка: SQL.


    1. snobit
      17.11.2022 14:46
      +6

      Это же слишком сложно, написать INSERT ... SELECT.
      Я бы предложил вынести все в микросевис, который будет удалять сообщения. Если сообщений станет много, можно в кубере поднять десяток инстансов и все.


      1. michael_v89
        17.11.2022 20:35
        +1

        INSERT… SELECT на больших нагрузках может вызывать взаимные блокировки и сбои транзакций.


  1. MaryRabinovich
    17.11.2022 13:29
    -1

    Оптимизируем больше - можно сразу только айдишники выбирать. Вместо того, чтобы выбрать сначала записи полностью, а дальше вытаскивать в массив айдишники через foreach, выбрать сразу только колонку ID.

    (хотя нет, это бессмысленно - вы всё равно выбираете сообщения полностью, чтобы переложить в архив)


  1. polar_yogi
    17.11.2022 13:36
    +5

    Почитайте хотя бы https://mariadb.com/kb/en/big-deletes/
    И про isolation levels тоже.
    А то такую фигню пишете что даже комментарий сложно написать.


    1. baldr
      17.11.2022 13:39
      +9

      Все сеньоры релоцировались и не успели рассказать джунам про транзакции, но забыли на столе книжку про ORM.


      1. IgorAlentyev
        19.11.2022 16:23

        При чем тут орм если боттлнек в SQL команде delete?


  1. vasilisc
    17.11.2022 13:52

    InnoDB не блокирует таблицу. Почитайте что действительно происходит при удалении большого количества записей.

    https://mariadb.com/kb/en/big-deletes/


  1. mafia8
    17.11.2022 14:03

    Что мешает удалить таблицу messages и снова создать её?


    1. baldr
      17.11.2022 14:36

      Во-первых, DDL-операции в MySQL вызывают неявный коммит, то есть транзакция закроется и все пишущие клиенты получат ошибки.

      Во-вторых, насколько я понял, надо не все записи удалить, а только часть старше нескольких дней.


  1. varanio
    17.11.2022 14:09
    +5

    Почему нельзя сделать просто запрос DELETE ... WHERE time < ...


    1. IgorAlentyev
      19.11.2022 16:24

      Потому что нужно еще и перенести сообщения в другую таблицу. И суть проблемы не изменится, операция удаления все еще будет боттлнеком.


      1. baldr
        19.11.2022 17:12

        Нет, не будет. Проблема не в удалении, а в условии "where id in (...)" - когда передаются 50к идентификаторов и база начинает все их искать сканированием, пусть даже и по индексу.

        Давайте я еще раз объясню как должен выглядеть правильный способ, полностью на стороне базы в SQL:

        • открываем транзакцию с уровнем изоляции REPEATABLE READ

        • выполняем INSERT INTO history_table (...) SELECT ... FROM messages_table WHERE message_time < '2022-11-01' (дата для примера, можно смещение от сегодняшней взять)

        • Выполняем DELETE FROM messages_table WHERE message_time < '2022-11-01'

        • COMMIT

        Всё! Не надо копировать миллионы записей из базы на сервер, переводить их в объекты или массивы на PHP, потом из них выбирать id, потом из них формировать какие-то слайсы и новые SQL-запросы на миллионы вставок, а потом еще и пачку удалений по id.. Про транзакции в статье не сказано ни слова и я вообще не уверен что они там были.

        Автор очень мудро не появляется больше в комментариях и не пытается научить нас еще каким-то новым "PHP-трюкам".


        1. IgorAlentyev
          19.11.2022 17:47

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

          То что вы описали действительно увеличит скорость, но несущественно, относительно времени именно удаления, так что это экономия на спичках.


  1. Akina
    17.11.2022 14:50
    +7

    А что мешает сделать таблицы партиционированными, например, посуточно? Тогда вместо удаления записей просто удаляем партицию - практически мгновенная операция.

    Но самое главное - непонятно, зачем вообще мучать PHP, когда всё описанное элементарно и гораздо менее нагрузочно выполняется на стороне MySQL.


    1. FanatPHP
      17.11.2022 15:35

      А диагностику проблемы он правильно описал? Про создание слепка, которое замедляет удаление?


      1. Akina
        17.11.2022 17:36

        Вот даже не вчитывался в эти подробности. Привык, что те, кто работает из языка или фреймворка, уровень СУБД просто "пробежали по верхам".

        На сколько это верно, и верно ли вообще - надо копать internals. Но мне сентенция о создании слепка на каждый чих кажется крайне сомнительной.


        1. IgorAlentyev
          20.11.2022 12:15

          Так может быть стоит разобраться, о чем идет речь, прежде чем писать своё мнение? Если так сделать, то окажется что и PHP и ORM тут вообще не при чем, и проблема как раз таки в том, как работает DELETE в MySql и его форках.


    1. IgorAlentyev
      19.11.2022 16:25

      Что вам пхп плохого сделал? Там боттлнек в удалении, где и так sql.


      1. IgorAlentyev
        20.11.2022 12:12

        Господа дизлайкеры, прежде чем миносовать, рекомендую ознакомиться с постом, комментариями и вот этой ссылкой - https://mariadb.com/kb/en/big-deletes/, где написано, в чем заключается проблема и какие есть варианты решения.


  1. a-tk
    17.11.2022 20:11
    -2

    `TRUNCATE TABLE` отработало бы ещё быстрее /s


  1. v3shin
    17.11.2022 22:45

    Не понял про лимит 50000: вы же, вроде, в коде не количество прописываете, а дату.

    А если добавлять разбивку по 100 сообщений, почему бы сразу не запускать скрипт с лимитом в 100?


  1. yeahga
    17.11.2022 22:46

    Берём id последнего сообщения на удаление (если они по порядку), далее:

    DELETE FROM messages WHERE id <= ?;


    1. baldr
      18.11.2022 13:35

      А вот это антипаттерн. Не надо считать id только растущим. Даже если это обычно так в большинстве случаев. Где-то может быть долгая транзакция, которая выделила себе id раньше всех, а закрылась позже всех. Где-то база может решить оптимизировать индексы и последовательности..


      1. Akina
        18.11.2022 19:55

        Я бы даже сказал - не надо считать, что значение id не может быть присвоено явным заданием, минуя генератор.


      1. yeahga
        18.11.2022 22:38
        +1

        Спасибо, буду иметь в виду. А что, если пометить флагом сообщения на перенос в историю, далее смотреть на этот флаг?

        UPDATE `messages` SET `to_history` = 1 WHERE `time` <= ?; INSERT INTO `messages_history` SELECT * FROM `messages` WHERE `to_history` = 1; DELETE FROM message WHERE `to_history` = 1;


        1. baldr
          19.11.2022 01:23

          Так иногда делают, но нужно еще учитывать такие вещи как движок БД.

          В PostgreSQL из-за механизма MVCC операция UPDATE создаст новую строку взамен старой. Старая будет не видна для новых транзакций, но все еще будет занимать место на диске. Это проблема Bloating для Postgres. После удаления строк место освободится только после autovacuum (а может и нет) или ручного запуска vacuum.

          В MySQL если старый движок MyISAM (что вряд ли), то там таких проблем не будет (нет транзакций - нет проблем. Или есть, но другие). Как это работает в InnoDB - я уже не знаю точно, но подозреваю что тоже не бесплатно.

          Однако, нужно определиться с тем какую проблему вы решаете этим. Боитесь что во время копирования придут новые строки и влезут в диапазон дат на удаление? Напрасно боитесь если вы все делаете в транзакции с правильным уровнем изоляции.

          У вас две транзакции? Тогда все еще сложнее - а что если этот флаг уже стоит у части строк - предыдущий запуск скрипта пытался отправить сообщения в историю, выбрал их, но что-то пошло не так... Или отправил в историю, а удалить не смог..


          1. yeahga
            19.11.2022 03:00

            нужно еще учитывать такие вещи как движок БД

            Исходил из того, что в статье речь о MySQL.

            выбрал их, но что-то пошло не так

            При следующем выполнении скопирует. Можно использовать INSERT IGNORE на всякий

            отправил в историю, а удалить не смог

            Аналогично, удалит в дугой раз

            Напрасно боитесь

            Согласен


  1. zifreesoft
    17.11.2022 22:46

    Простой delete where... Зачем городить? Было бы быстрее и проще.


  1. Shrizt
    17.11.2022 23:26
    +2

    Где же вас размножают то, горе трюкачей... мрак


  1. LeMaX
    18.11.2022 14:16

    Удаляем блоками или разделами...

    innodb не блокирует таблицу, но при больших кол-вах записей на удаление расходует ресурсы и долбит журнал увеличивая ввод/вывод.


  1. ilyaplot
    19.11.2022 15:59

    Сделайте партиционирование сообщений по дате и удаляйте партиции, а не записи в таблицах.


  1. miruzzy
    20.11.2022 07:34

    Поправьте, если ошибаюсь

    Разве такая конструкция не подойдёт?

    With tt as( delete from messages returning *) insert into mess_history select * from tt

    PS пишу с телефона , возможны ошибки