Технические заметки одного Евтуховича

Рассказ о серых трудовых буднях инженера программных систем

Партиционирование

| Комментарии

Я долго считал партиционирование плохой практикой, а само слово не любил из-за кальки с английского, которую крайне сложно выговорить с первого раза. И если слово «партиционирование» я так с первого раза и не выговариваю, то саму практику пришлось признать как необходимое и неизбежное зло. Чтобы никто не подумал, что я делаю что-то плохое, я использую для этого термин «инженерный компромисс», звучит умнее и не так обидно.

Если бы партиционирование укладывалось в рамки официальной документации, то и писать бы о нем не стоило. Но есть особенности, которые не сразу ясны из официальной документации, либо, вообще, в ней не раскрываются.

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

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

Во-первых, в CHECK CONSTRAINTS должны быть IMMUTABLE функции. День у меня ушел на то, чтобы понять, что TIMESTAMP WITH TIME ZONE не является IMMUTABLE.

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

class CreatePartitionsForArchiveTransfers < ActiveRecord::Migration
  def up
    # Таблица фактов называется archive_transfers, мы разобъем ее на части по месяцам

    start = Date.parse '2011-10-01'
    trigger_parts = []   # здесь будут храниться куски триггера на вставку
    # сгенерируем триггер и таблицы до 2014-09 включительно - 3 года = 36 месяцев
    36.times do |i|
      date = start + i.month
      # таблицы будут иметь названия вида archive_transfers_2013_03
      table_name = "archive_transfers_#{date.strftime('%Y_%m')}"

      # b и e - сокращения от begin и end
      b = "'" + date.to_time_in_current_zone.utc.strftime('%Y-%m-%d %H:%M:%S') + "'"
      e = "'" +
        (date + 1.month).to_time_in_current_zone.utc.strftime('%Y-%m-%d %H:%M:%S') + "'"

      trigger_parts << " ( NEW.created_at >= #{b} AND
               NEW.created_at < #{e} ) THEN
              INSERT INTO #{table_name} VALUES (NEW.*);
      "

      create_table table_name, :options => "inherits (archive_transfers)" do |t|
      end

      # индексы не наследуются, поэтому для каждой
      # дочерней таблицы их надо создавать заново
      add_index table_name, :sender_id
      add_index table_name, :receiver_id

      # для каждой дочерней табилцы создаем проверку,
      # чтобы в нее попадали только нужные данные
      execute "ALTER TABLE #{table_name} ADD CHECK (
        created_at >= #{b} AND
        created_at < #{e})"
    end

    execute %Q(
      CREATE OR REPLACE FUNCTION archive_transfers_insert_trigger()
      RETURNS TRIGGER AS $$
      BEGIN
          IF ) + trigger_parts.join('ELSEIF') +
      %Q(    ELSE
              RAISE EXCEPTION 'Date out of range. Fix the archive_transfers_insert_trigger() function!';
          END IF;
          RETURN NULL;
      END;
      $$
      LANGUAGE plpgsql;
    )

    execute %Q(
      CREATE TRIGGER insert_archive_transfers_trigger
      BEFORE INSERT ON archive_transfers
      FOR EACH ROW EXECUTE PROCEDURE archive_transfers_insert_trigger();)

  end
end

После выполнения мы получим 36 новых таблиц в БД и триггер, похожий на этот.

CREATE OR REPLACE FUNCTION archive_transfers_insert_trigger()
RETURNS TRIGGER AS $$
BEGIN
  IF  ( NEW.created_at >= '2011-09-30 20:00:00' AND
          NEW.created_at < '2011-10-31 20:00:00' ) THEN
         INSERT INTO archive_transfers_2011_10 VALUES (NEW.*);
  ELSEIF ( NEW.created_at >= '2011-10-31 20:00:00' AND
          NEW.created_at < '2011-11-30 20:00:00' ) THEN
         INSERT INTO archive_transfers_2011_11 VALUES (NEW.*);
% здесь пропустим много таких же строчек
  ELSEIF ( NEW.created_at >= '2014-08-31 20:00:00' AND
          NEW.created_at < '2014-09-30 20:00:00' ) THEN
         INSERT INTO archive_transfers_2014_09 VALUES (NEW.*);
  ELSE
     RAISE EXCEPTION 'Date out of range.  Fix the archive_transfers_insert_trigger() function!';
  END IF;
  RETURN NULL;
END;
$$
LANGUAGE plpgsql;

При партиционировании перестает работать RETURNING, а это значит, что при вставке новой записи нельзя узнать ее id. Для этого существует костыль, который на каждую вставку делает дополнительную вставку и удаление, чтобы получить id записи. Я не рискнул использовать его в бою, поскольку у нас и так очень интенсивная нагрузка на БД.

Более того, надо понимать, что в случае с партиционироваными таблицами, вы можете иметь одинаковые id для разных записей, так как уникальность id проверяется (если проверяется) только на уровне конкретной дочерней таблицы. Если вы вставляете данные только в главную таблицу archive_transfers, то id гарантированно будут отличаться, потому что триггер использует sequence от главной таблицы. Но ничего не запрещает вам вставить в дочернюю таблицу данные напрямую.

Какой выигрыш от такого усложнения? Во-первых, вместо одного большого индекса у вас будет теперь много маленьких, которые помещаются в память. Если вам надо сделать выборку по дате, то seq scan будет идти только по нужным партициям. В нашем случае, например, все запросы, в основном, делаются по последнему месяцу, поэтому она оказывается в кэше БД и, самое главное, помещается туда целиком. А как мы знаем, БД для web-проекта либо помещается в память, либо не работает, но об этом я напишу как-нибудь в другой раз.

Сам понимаю, что получилось немного сумбурно, поэтому с радостью раскрою непонятные вопросы в комментариях.

Комментарии