Когато кешът е по-бавен от една заявка

Открих, че преработвам голяма част от нашия ETL код поради зле проектиран кеш и искам да споделя резюме на моите знания.

tl;dr; Предварително заредете всички данни, които програмата вероятно ще изисква, вместо да правите заявки и да кеширате данни, когато са необходими.

Входни данни

Първо, позволете ми да настроя сцената. Вие събирате данни от хиляди кина (това правим ние в http://applaudience.com/). Тези данни включват места за всяко шоу, напр.

{
  "name": "A1",
  "state": "SOLD",
  "type": "TIERtypePREMIUM",
},
{
  "name": "A2",
  "state": "SOLD",
  "type": "TIERtypePREMIUM",
},
{
  "name": "A3",
  "state": "SOLD",
  "type": "TIERTIER_1_PREMIUMPREMIUM",
}
{
  "name": "A4",
  "state": "SOLD",
  "type": "TIERTIER_1_PREMIUMPREMIUM",
},
{
  "name": "A5",
  "state": "SOLD",
  "type": "TIERtypePREMIUM",
},
{
  "name": "A6",
  "state": "SOLD",
  "type": "TIERtypePREMIUM",
}

Изискването е да съпоставите чужди субекти с местни субекти.

Примерен тип обект е тип седалка (type в горния пример), т.е. киното описва типовете седалки, използвайки произволни идентификатори (напр. TIER_1_PREMIUM, TIER_2_PREMIUM) и програмата, която получава данни, трябва да идентифицира дали вече знае за този чужд тип седалка:

  1. Ако програмата разпознае чуждия идентификатор на типа седалка, тогава тя трябва да върне свързания локален идентификатор.
  2. Ако програмата не разпознае чуждия идентификатор на типа седалка, тогава тя трябва да създаде запис в локалната база данни и да върне свързания локален идентификатор.

Този модел се появява винаги, когато работите с външни източници на данни, които трябва да бъдат синхронизирани с локална база данни.

За да синхронизираме нашата база данни с емисии на кино, ние:

  1. първо идентифицирайте всички атрибути на данните, предоставени от киното
  2. проверете дали последното известно състояние е различно от текущото състояние
  3. запишете ново състояние, ако е различно от последното състояние

Грешната стратегия за кеширане беше в стъпка #1.

Идентификация на чужд атрибут

За да идентифицираме всички атрибути, трябва да преминем през всички седалки и да актуализираме или вмъкнем (upsert) техните идентификатори, което правим в този код:

for (const seat of seatingPlan.seats) {
  const cinemaForeignSeatNameId = await upsertCinemaForeignSeatName(
    connection,
    cinemaId,
    seat.name
  );
  const cinemaForeignSeatStateId = await upsertCinemaForeignSeatState(
    connection,
    cinemaId,
    seat.state
  );
  const cinemaForeignSeatTypeId = await upsertCinemaForeignSeatType(
    connection,
    cinemaId,
    seat.type
  );
  seatIdentifiers.push({
    cinemaForeignSeatNameId,
    cinemaForeignSeatStateId,
    cinemaForeignSeatTypeId,
  });
}

Всички upsert* методи се изпълняват с помощта на помощната програма Slonik upsert, която първо ще се опита да SELECT ресурс и ако ресурсът не бъде намерен, ще се опита да INSERT ресурса (актуализацията не влиза в действие, тъй като колоните за търсене се състоят само от колони, които създават уникалното ограничение).

const upsertCinemaForeignSeatType = async (
  connection: DatabaseConnectionType,
  cinemaId: DatabaseRecordIdType,
  foreignSeatType: string
): Promise<DatabaseRecordIdType> => {
  return upsert(
    connection,
    'cinema_foreign_seat_type',
    {
      cinemaId,
      foreignSeatType,
    },
    [
      'cinema_id',
      'foreign_seat_type',
    ]
  );
};

upsertCinemaForeignSeatType връща локален идентификатор на тип седалка, ако може да намери запис в cinema_foreign_seat_type таблица със съвпадащ (cinema_id, foreign_seat_type).

Използване на кеш

Както вероятно вече сте заключили от примерния вход, стойностите state и type се повтарят често в една и съща емисия на кино. Следователно има смисъл заявката да се кешира, напр.

const MINUTE = 60 * 1000;
const memoizeQuery = (query, max = 5000, maxAge = 60 * MINUTE) => {
  return memoizee(query, {
    max,
    maxAge,
    normalizer: (args) => {
      return JSON.stringify([].slice.call(args, 1));
    },
    promise: true,
  });
};
const upsertCinemaForeignSeatStateUsingCache = memoizeQuery(upsertCinemaForeignSeatState);
const upsertCinemaForeignSeatTypeUsingCache = memoizeQuery(upsertCinemaForeignSeatType);
for (const seat of seatingPlan.seats) {
  const cinemaForeignSeatNameId = await upsertCinemaForeignSeatName(
    connection,
    cinemaId,
    seat.name
  );
  const cinemaForeignSeatStateId = await upsertCinemaForeignSeatStateUsingCache(
    connection,
    cinemaId,
    seat.state
  );
  const cinemaForeignSeatTypeId = await upsertCinemaForeignSeatTypeUsingCache(
    connection,
    cinemaId,
    seat.type
  );
  seatIdentifiers.push({
    cinemaForeignSeatNameId,
    cinemaForeignSeatStateId,
    cinemaForeignSeatTypeId,
  });
}

Тази промяна означава, че операциите state и type upsert ще изпълнят рутинните си процедури най-много 1 път за всяка уникална комбинация (cinema_id, state) и (cinema_id, type). Имайки предвид, че всяка зала има 200–300 места, това вече е доста добро подобрение.

Проблемът с този подход е, че той приема, че кардиналността на state и type е малка. Ами ако стойностите state и type са уникални за всяко място? Това би означавало, че трябва да изпълним upsert за всяко място или ~600 заявки на кино. Точно това се случи в нашия случай. Установихме, че няколко големи кина ни подават с уникални стойности състояние/тип стойности за всяко място, което води до над 10 милиона ненужни заявки на ден.

Трябва да отбележа, че 10 милиона заявки не са задължителен проблем. Можем лесно да се справим с това – всъщност PostgreSQL Shared Buffer Cache най-вече ще направи тези заявки незабележими. Основният проблем е наводняването на регистрационни файлове и ненужното мрежово време.

Предварително зареждане на данни, които вероятно ще бъдат използвани

Има обаче по-добър начин да го направим – можем да заредим предварително всички данни за киното в една заявка, т.е. вместо да използваме (cinema_id, type) параметри за търсене на съществуване на cinema_foreign_seat_type, можем да търсим всички cinema_foreign_seat_type за cinema_id, да създадем речник и да използваме речник за търсене на чуждо място type, напр.

const createUpsertCinemaForeignSeatType = async (
  connection: DatabaseConnectionType,
  cinemaId: DatabaseRecordIdType,
) => {
  const dictionary = {};
  const cinemaForeignSeatTypes = await connection.any(sql`
    SELECT fuid, id
    FROM cinema_foreign_seat_type
    WHERE cinema_id = ${cinemaId}
  `);
  for (const cinemaForeignSeatType of cinemaForeignSeatTypes) {
    dictionary[cinemaForeignSeatType.fuid] = cinemaForeignSeatType.id;
  }
return (fuid: string): Promise<DatabaseRecordIdType> => {
    if (dictionary[fuid]) {
      return dictionary[fuid];
    }
    return upsert(
      connection,
      'cinema_foreign_seat_type',
      {
        cinemaId,
        fuid,
      },
      [
        'cinema_id',
        'fuid',
      ]
    );
  };
};

Използвайки тази техника, ние изпълняваме само една заявка, за да извлечем всички вече известни типове места (които ще останат непроменени през повечето време), т.е. трябва да изпълним само upsertрутина, когато входните данни съдържат невиждан досега тип седалка. Останалата част от кода остава непроменена и не е необходим кеш.

Ползата от този подход е, че намаляваме броя на мрежовите заявки, които са необходими за разпознаване на данните, и също така ограничаваме нашите предположения за състоянието на базата данни до една транзакция (обектът dictionary). Този модел работи винаги, когато знаете някои атрибути на данните, които трябва да извлечете (напр. cinema_id в този случай).

Има обаче една грешка – какво ще стане, ако има милиони записи, които са идентифицирани само с cinema_id? Заобиколихме това, като добавихме атрибут last_seen_at към cinema_foreign_seat_type. Ние актуализираме този атрибут веднъж на ден и извличаме само записи, които са били видени през последните 30 дни.

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

p.s. Ако се чудите каква е причината да не можем да изтеглим всички типове седалки и след това да ги търсим с помощта на нещо като SELECT fuid, id FROM cinema_foreign_seat_type WHERE cinema_id = $1 AND fuid = ANY($2::text[]), тогава това е валидно предложение и трябва да се вземе предвид в зависимост от вашия полезен товар. Следвайте тази дискусия в Reddit, за да научите защо вероятно няма да работи в нашата настройка. Винаги еталон!