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

Мы на пальцах рассмотрим что такое шардирование, как оно помогает в масштабировании и даже рассмотрим тот самый этап «роста».

О чём речь?

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

Шардирование помогает оптимизировать хранение данных приложения за счёт их распределения между инсталляциями БД (которые находятся на разных железках), что улучшает отзывчивость сервиса, так как размер данных в целом на каждом инстансе станет меньше. 

Шардирование — это разновидность партиционирования (от англ. partition — деление, раздел). Отличие в том, что партиционирование подразумевает разделение данных внутри одной БД, а шардирование распределяет их по разным экземплярам БД. 

Способы шардирования

Осуществить шардирование можно несколькими способами:

  1. Средствами БД. Некоторые базы — MongoDB, Elasticsearch, ClickHouse и другие — умеют самостоятельно распределять данные между своими экземплярами, для этого достаточно настроить конфигурацию. На мой взгляд, это лучший вариант.

  2. Надстройками к БД. Самый спорный способ — применение надстроек, которые выполняют шардирование, например Vitess или Citus, поскольку при этом есть риск потери данных и производительности.

  3. Клиентскими средствами. В этом случае экземпляры БД даже не подозревают о существовании друг друга, шардированием управляет стороннее приложение — со всеми вытекающими рисками.

Методы работы в этих способах схожи: мы выбираем ключ для распределения данных (это может быть идентификатор, временная метка или хеш записи) и в соответствии с ним записываем информацию в нужный шард. Как правило, ключи стараются выбирать так, чтобы данные были равномерно распределены по шардам. Сделать это не сложно — достаточно ориентироваться на текущее содержимое БД. 

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

Пример шардирования

Давайте в качестве примера сделаем клиентское шардирование горячо любимой в Ozon PostgreSQL. Приложение будет на Go, а мигрировать будем с помощью Goose. Для начала нам надо добавить сами шарды, то есть развернуть еще одну инсталляцию БД. Отвлекаться на детальный разбор того, как правильно раскатывать PostgreSQL, мы не будем.

Добавим в наш Storage маппинг шардов: 

// Обозначим количество шардов.
const bucketQuantity = 2

const (
    Shard1 ShardNum = iota
    Shard2
)
// Для лучшей семантики.
type ShardNum int
type shardMap map[ShardNum]*sqlx.DB

type Storage struct {
    shardMap shardMap
}

Напишем конструктор для Storage который возьмёт на себя все задачи по инициализации соединений с БД.

func initShardMap(ctx context.Context, dsns map[ShardNum]string) shardMap {
    m := make(shardMap, len(dsns))
    for sh, dsn := range dsns {
   	 m[sh] = discoveryShard(ctx, dsn)
    }

    return m
}

func discoveryShard(ctx context.Context, dsn string) *sqlx.DB {
    db, err := sqlx.ConnectContext(ctx, "postgres", dsn)
    if err != nil {
   	 panic(err)
    }

    return db
}

func NewStorage(ctx context.Context, dsns map[ShardNum]string) *Storage {
    return &Storage{
   	 shardMap: initShardMap(ctx, dsns),
    }
}

Переходим к работе с данными. Реализуем методы для их записи в шарды и чтения оттуда. Начинается всё с определения того, в какой шард идти. 

При условии равномерности распределения наших ID (представим, что это действительно так) нам хватит классического остатка от деления. Выглядеть это будет примерно так:

func (s *Storage) shardByItemID(itemID int64) ShardNum {
   return ShardNum(itemID % bucketQuantity)
}

У нас есть вот такой незаурядный  метод чтения из БД. Тут стоит обратить внимание на то, что мы выполняем запрос на инстансе БД из нашего маппинга, а получаем инстанс (*sqlx.DB) по идентификатору шарда из сигнатуры.

func (s *Storage) getItemsByID(ctx context.Context, shard *sqlx.DB, itemsIDs []int64) ([]models.Item, error) {
   items := make([]models.Item, 0)
 
   query, args, err := sq.
       Select(itemsTableFields...).
       From(itemsTable).
       Where(sq.Eq{itemIDField: itemsIDs}).
       PlaceholderFormat(sq.Dollar).
       ToSql()
   if err != nil {
       err = errors.Wrap(err, "[create query]")
       return items, err
   }
 
   err = shard.SelectContext(ctx, &items, query, args...)
   return items, err
}

Сам идентификатор шарда мы получаем чуть выше, когда распределяем наши ItemIDs по кубышкам. Само распределение выглядит вот так:

func (s *Storage) sortItemsIDsByShard(itemIDs ...int64) map[ShardNum][]int64 {
   shardToItems := make(map[ShardNum][]int64)
 
   for _, id := range itemIDs {
       shardID := s.shardByItemID(id)
       if _, ok := shardToItems[shardID]; !ok {
           shardToItems[shardID] = make([]int64, 0)
       }
 
       shardToItems[shardID] = append(shardToItems[shardID], id)
   }
 
   return shardToItems
}

Ну и инфраструктурная обёрточка — чтобы запросы выполнялись параллельно. Вот так будет выглядеть публичный метод получения Item. Кажется, что он довольно большой, но в действительности  большую часть метода съедают раскручивания каналов.

func (s *Storage) GetItems(ctx context.Context, itemIDs ...int64) ([]models.Item, error) {
   shardToItems := s.sortItemsIDsByShard(itemIDs...)
 
   respChan := make(chan []models.Item, len(shardToItems))
   errChan := make(chan error, len(shardToItems))
   wg := &sync.WaitGroup{}
 
   for shardID, ids := range shardToItems {
       wg.Add(1)
       shard := s.shardMap[shardID]
       go s.asyncGetItemsByID(ctx, shard, ids, wg, respChan, errChan)
   }
 
   wg.Wait()
   close(respChan)
   close(errChan)
 
   result := make([]models.Item, 0)
   for items := range respChan {
       result = append(result, items...)
   }
 
   errs := make([]error, 0, len(errChan))
   for e := range errChan {
       errs = append(errs, e)
   }
   err := multierr.Combine(errs...)
 
   return result, err
}

Для того чтобы не терять смысл getItemsByID за нагромождением каналов и Wait-групп, мы просто обернем всё это в asyncGetItemsByID:

unc (s *Storage) asyncGetItemsByID(
   ctx context.Context,
   shard *sqlx.DB,
   itemsIDs []int64,
   wg *sync.WaitGroup,
   resp chan<- []models.Item,
   errs chan<- error,
) {
   defer wg.Done()
   items, err := s.getItemsByID(ctx, shard, itemsIDs)
   if err != nil {
       errs <- errors.Wrapf(err, "[getItemsByID] can't select from shard %d", shard)
   }
 
   resp <- items
}
Всё то же самое мы проделываем для записи данных в шарды:
func (s *Storage) AddItems(ctx context.Context, items ...models.Item) error {
   itemsByShardMap := s.itemsByShard(items...)
   errChan := make(chan error, len(itemsByShardMap))
   wg := &sync.WaitGroup{}
 
   for shardID, items := range itemsByShardMap {
       wg.Add(1)
       shard := s.shardMap[shardID]
       go s.asyncAddItems(ctx, errChan, wg, shard, items...)
   }
 
   wg.Wait()
   close(errChan)
 
   errs := make([]error, 0, len(errChan))
   for e := range errChan {
       errs = append(errs, e)
   }
 
   return multierr.Combine(errs...)
}
 
func (s *Storage) itemsByShard(items ...models.Item) map[ShardNum][]models.Item {
   itemsByShard := make(map[ShardNum][]models.Item)
 
   for _, item := range items {
       shardID := s.shardByItemID(item.ID)
       if _, ok := itemsByShard[shardID]; !ok {
           itemsByShard[shardID] = make([]models.Item, 0)
       }
 
       itemsByShard[shardID] = append(itemsByShard[shardID], item)
   }
 
   return itemsByShard
}
 
func (s *Storage) asyncAddItems(
   ctx context.Context,
   errChan chan<- error, wg *sync.WaitGroup,
   shard *sqlx.DB,
   items ...models.Item) {
   defer wg.Done()
   err := s.addItems(ctx, shard, items...)
   errChan <- errors.Wrapf(err, "[asyncAddItems] can't insert to shard")
}
 
func (s *Storage) addItems(ctx context.Context, shard *sqlx.DB, items ...models.Item) error {
   q := sq.
       Insert(itemsTable).
       Columns(itemsTableFields...).
       PlaceholderFormat(sq.Dollar)
 
   for _, item := range items {
       q = q.Values(item.ID, item.CreatedAt)
   }
 
   query, args, err := q.ToSql()
   if err != nil {
       return errors.Wrap(err, "[create query]")
   }
 
   _, err = shard.DB.ExecContext(ctx, query, args...)
   return err
}

Ну и скриптик для миграции всего этого дела:

#!/usr/bin/env bash
export MIGRATION_DIR=./migrations/
 
if [ "${STAGE}" = "production" ]; then
   if [ "$1" = "--dryrun" ]; then
       goose -dir ${MIGRATION_DIR} postgres "user=${USER1} password=${PASSWORD1} dbname=${DBNAME1} host=${HOST1} port=${PORT1} sslmode=disable" status
       goose -dir ${MIGRATION_DIR} postgres "user=${USER2} password=${PASSWORD2} dbname=${DBNAME2} host=${HOST2} port=${PORT2} sslmode=disable" status
   else
       goose -dir ${MIGRATION_DIR} postgres "user=${USER1} password=${PASSWORD1} dbname=${DBNAME1} host=${HOST1} port=${PORT1} sslmode=disable" up
       goose -dir ${MIGRATION_DIR} postgres "user=${USER2} password=${PASSWORD2} dbname=${DBNAME2} host=${HOST2} port=${PORT2} sslmode=disable" up
   fi
 
elif [ "${STAGE}" = "staging" ]; then
   if [ "$1" = "--dryrun" ]; then
       goose -dir ${MIGRATION_DIR} postgres "user=${USER1} password=${PASSWORD1} dbname=${DBNAME1} host=${HOST1} port=${PORT1} sslmode=disable" status
   else
       goose -dir ${MIGRATION_DIR} postgres "user=${USER1} password=${PASSWORD1} dbname=${DBNAME1} host=${HOST1} port=${PORT1} sslmode=disable" up
   fi
elif [ "${STAGE}" = "development" ]; then
   exit 0
fi

Очень удобно шардировать в приложении, где еще нет данных, а следовательно нет необходимости их перетаскивать. Но что делать, если мы шардим рабочее приложение? Тут, как говорится у нас на Руси, case-by-case.

  • Приложение можно остановить? Прекрасно! Останавливаем разбор очередей, отключаем обработку запросов или вовсе останавливаем приложение, делаем резервную копию базы, если ещё не сделали, перетаскиваем данные в соответствии с выбранным ключом и снова вводим приложение в эксплуатацию, предварительно проведя регрессионное тестирование.

  • Приложение должно отвечать на запросы? Тогда делаем временную надстройку внутри репозитория. Пишем данные всегда в новые шарды. Читаем сначала из нового шарда; если там нет нужных данных, то обращаемся к старым шардам. Если данные оказываются в старом шарде, то при желании можно их переносить в новый — и в конце концов данные перераспределятся между шардами. Только не забудьте добавить метрики, чтобы не пропустить этот знаменательный момент. И обязательно проверьте, все ли данные переехали или только те, к которым идут запросы. Да, и на первых порах это не то чтобы положительно скажется на отзывчивости приложения, будьте к этому готовы. 

  • Если приложение пишет данные в БД только по событиям из условной kafka, а синхронные запросы (REST/GRPC) только читающие (классическая ситуация для event sourcing), то мы отключаем чтение из kafka, выкатываем в prod инстанс версии приложения, которое уже живет с новой схемой шардов, но синхронные запросы шлем только на инстанс приложения предыдущей версии (оно же canary-deploy). Далее джоба внутри приложения последовательно читает данные по старому маппингу, и пишет в новый, после переноса можно сразу же и удалять данные в старой схеме. 

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

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

Начинается всё с создания дублирующей схемы шардов внутри Storage. Внесём изменения в константы:

const legacyBucketQuantity = 2
const bucketQuantity = 3
 
const (
   Shard1 ShardNum = iota
   Shard2
   Shard3
)

Заведём внутри Storage shardMapLegacy, который содержит дорешардинговый маппинг:

type Storage struct {
    shardMapLegacy shardMap
    shardMap   	shardMap
}

Ну и инициализация. В конструктор Storage теперь будем передавать также две схемы:

func NewStorage(ctx context.Context, dsns map[ShardNum]string, dsnsLegacy map[ShardNum]string) *Storage {
   return &Storage{
       shardMap:       initShardMap(ctx, dsns),
       shardMapLegacy: initShardMap(ctx, dsnsLegacy),
   }
}

Заводим метод для получения shardID, чтобы после переноса данных его удалить:

func (s *Storage) legacyShardByItemID(itemID int64) ShardNum {
    return ShardNum(itemID % legacyBucketQuantity)
}

Ну и ещё чуть-чуть дублирования кода. Речь о практически полной копии sortItemsIDsByShard; разница лишь в том, что для получения идентификатора шарда мы используем ранее модифицированную функцию. 

func (s *Storage) sortItemsIDsByLegacyShard(itemIDs ...int64) map[ShardNum][]int64 {
    shardToItems := make(map[ShardNum][]int64)

    for _, id := range itemIDs {
   	 shardID := s.legacyShardByItemID(id)
   	 if _, ok := shardToItems[shardID]; !ok {
   		 shardToItems[shardID] = make([]int64, 0)
   	 }

   	 shardToItems[shardID] = append(shardToItems[shardID], id)
    }

    return shardToItems
}

Метод добавления Items в изменении не нуждается, так как мы условились, что данные пишем всегда в свежие шарды, а вот GetItems надо подправить. Теперь он будет конкурентно выполнять запрос сразу в две схемы, а полученные данных мы будем склеивать, отдавая предпочтение данным с актуального маппинга шардов.

func (s *Storage) GetItems(ctx context.Context, itemIDs ...int64) ([]models.Item, error) {
   wg := &sync.WaitGroup{}
 
   resultLegacy := make([]models.Item, 0)
   resultActual := make([]models.Item, 0)
 
   var err error
 
   wg.Add(1)
   go func() {
       defer wg.Done()
 
       res, e := s.getItems(ctx, itemIDs...)
       err = multierr.Append(err, e)
       resultActual = res
   }()
 
   wg.Add(1)
   go func() {
       defer wg.Done()
 
       res, e := s.getItemsFromLegacyShardMap(ctx, itemIDs...)
       err = multierr.Append(err, e)
       resultLegacy = res
   }()
 
   wg.Wait()
   result := mergeItems(resultActual, resultLegacy)
 
   return result, err
}

Склейка результата выглядит так: 

func mergeItems(items, legacyItems []models.Item) []models.Item {
   itemsMap := make(map[models.Item]struct{})
 
   for _, item := range legacyItems {
       itemsMap[item] = struct{}{}
   }
 
   for _, item := range items {
       itemsMap[item] = struct{}{}
   }
 
   mergedItems := make([]models.Item, 0, len(itemsMap))
   for item, _ := range itemsMap {
       mergedItems = append(mergedItems, item)
   }
 
   return mergedItems
}

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

При таком подходе остаётся только один вопрос: что делать с «мёртвыми» данными,  которые лежат не в своих шардах после решардинга? 

Вариант в лоб: предположим, что мы зарешардились с двух до четырёх шардов, идём — и на каждом легаси-шарде выполняем запрос на удаление записей, где полученный для ID ключ шардирования не соответствует текущему шарду:

DELETE FROM items
WHERE ctid IN (
   SELECT ctid
   FROM items
   WHERE id % 4 NOT IN (2, 2-4)
   );

З.Ы. Вариант SQL-запроса предполагает, что мы храним гошный UInt64 в постгревом BigInt. В этом случае положительные гошные числа могут превратиться в отрицательные постгревые, поэтому делаем NOT IN для ренджа.

Иногда встречаются системы, где данные имеют свойства ‎‎‎‎«протухать». В таких системах самое логичное оставить данные после решардинга, и дождаться пока они «протухнут».

И пара слов об упячках, с которыми я сталкивался, — о партиционировании внутри одной БД и шардинге целыми партициями. Поначалу кажется, что это логично и даже элегантно. Ведь для решардинга достаточно просто перетащить целую партицию с одного шарда в другой. И это ФАТАЛЬНАЯ ОШИБКА. Со временем вы устанете от трёхэтажного мата негодования, вызванного пятиэтажными пакетными запросами, из-за которых горячие данные не будут нормально попадать в кеш. Такой способ работает лишь в том случае, если партиционирование выполняется по дате, но запросы, как правило, обращаются к свежим или старым данным, как, например, во многих OLAP-системах. В остальных случаях перспективнее держать данные в рамках одной партиции, а решардить их путём постепенного переноса, если, конечно, БД не предусматривает своих вариантов решения проблемы решардинга.

Вместо вывода

Рано или поздно вам придётся заняться решардингом. А потом ещё раз, и ещё, и ещё, особенно, если бизнес будет расти и данных будет становиться всё больше. Поэтому приберегите инструменты, которые вам помогли однажды, и ничего страшного, если запускать вы их будете раз в полгода. 

Клиентское шардирование — надо. 

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

Не так страшно шардирование, как решардинг. Важно заранее подумать о том, как вы будете решать вопросы консистентности при решардинге.

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


  1. Sabirman
    19.12.2022 11:56
    +1

    Почему было выбрано шардирование для распределения нагрузки ?

    (Если не хранить в БД логи и файлы, то операций записи в базу будет относительно не много. А операции по чтению замечательно распределяются по readonly-репликам. )


    1. worldbug Автор
      19.12.2022 12:40
      +3

      Очень хорошо про логи подмечено, логи как правило это небольшие записи но они делаются очень часто, читаются редко. Логи к тому же редко хранятся в реляционных базках, о которых и шла речь в статье.
      А вот когда у тебя много данных пишется и читается, причем с произвольным доступом, и ко всему к этому есть еще не функциональное требование "надо что бы отвечало менее 30мс". То шардинг тут не не сказать что избыточен, это классический путь размазывания нагрузки по железкам. Синхронные реплики помогут растащить читающую нагрузку, безусловно, но в записи нам это все еще ничего не прибавит.
      И тут как всегда мы начинаем искать компромисс. И шардирование порой проще чем внедрение условного KV, особенно в больших компаниях.
      С тезисом что в подавляющем большинстве случаев шардинг избыточен можно согласится, потому что наверное большая часть приложений в мире не имеет больших нагрузок и жестких требований к отзывчивости.


    1. perfectdaemon
      19.12.2022 12:42
      +1

      1. Если коммит не синхронный, то реплики могут отставать. Если синхронный, то скорость записи сильно падает. С этим можно жить, но см. пункт 2.

      2. Если коммит несинхронный, то может возникнуть ситуация, когда WAL копится и не применяется на репликах из-за постоянных запросов. В итоге WAL забьется, и упадёт запись.

      3. Статистика хранится на мастере и реплицируется на реплики, то есть читать только с реплик нельзя, нужно чтение и на мастере

      4. Рано или поздно размер данных превысит какой-то порог, индекс перестанет попадать в кеш целиком, поедет статистика, pg начнет ошибаться в планах

      Это из тех проблем, что я навскидку видел при отсутствии шардинга на условно больших Postgres базах


  1. MonkAlex
    19.12.2022 12:09
    +3

    Я не понял - в начале пишется, что лучше шардировать средствами БД, а статья про приложения.

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


    1. worldbug Автор
      19.12.2022 12:23
      +1

      Если БД поддерживает нативное шардирование, то конечно лучше использовать его. В случае с postgresql к сожалению нативного шардирования нет.

      На воторой вопрос - в решардинге самая главная боль это перераспределение данных, особенно когда их много и за ними часто ходят. Оставлять эту задачу на автоматику откровенно говоря страшно. А если речь идет о том что на этапе проектирования надо закладывать пространство для маневра шардирования, это так, вопрос в том что считается "достаточным" пространством для маневра. Просто мапинг бакета на шард или сценарий переноса данных который запускается по кнопке, а будет ли он актуален на момент нового решардинга ? Например может изменится инфраструктура или требования к приложению, из за чего предыдущий подход к решардингу будет не оптимальный. Коротко говоря, все хотели бы иметь какой то универсальный набор правил для построения систем с быстрым решардингом, но у всех слишком разные приложения и требования к ним.


      1. oxff
        19.12.2022 16:06

        Постгрес поддерживает шардирование при помощи комбинации партиционирования + FDW. Каждая секция таблицы смотрит на свой foreign server (который может быть HA кластером)


  1. gleb_l
    19.12.2022 22:09

    ulong/bigint - я хоть убей не понимаю, почему разработчики БД не сделают наконец беззнаковые типы? Это какое-то профессиональное упрямство продолжать выпускать версию за версией движков всех мастей с сотнями новых фич управления данными, но без совместимости с полнодиапазонными типами современных (уже лет 40 как) процедурных ЯП.


  1. Neveil
    20.12.2022 01:45
    +2

    А почему просто не перейти на шардируемую БД?


    1. worldbug Автор
      20.12.2022 11:40

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


  1. gsl23
    20.12.2022 10:11
    +3

    Задачу получения консистентного бэкапа всех шардов как решили ?


    1. worldbug Автор
      20.12.2022 12:03

      Конкретно в сервисах нашей команды мы эту задачу не решали. Нам повезло. У нас достаточно держать данные консистентными в рамках одного шарда. Вопросом бекапов самих баз у нас занимается выделенная команда. 
Консистентный бекап это проблема которой стоит избегать, стараясь создавать такую схему данных, которая позволит определять шард как нечто автономное. Например нам надо шардировать БД с карточками товара, которая содержит инфу о товаре, цену, комментарии.
      В случае если мы шардируем карточку по слоям - инфу о товаре на один шард, цену на второй а коменты на третий. То встает вопрос консистетного бекапа. А если мы шардируем по принципу id-товара%кол-во шардов = номер шарда на котором лежит и инфа о товаре и цена и коменты, то достаточно решить проблему надежного бекапирования каждого шарда отдельно.


  1. kirill_petrov
    20.12.2022 17:16

    @worldbugинтересный опыт, спасибо, что поделились.

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

    Вы, как овнеры сервисы понятно, что адаптировались к новой реальности. А что произошло с вашими клиентами:

    1. Подразумевается, что клиент, который вызывает getItemsByIDдолжен сам ретраить, если запрос до базы не прошел? При этом какая обычно логика ретраев у ваших клиентов - запросить все заново или только то, что не пришло, или он заранее знает, что там CA система (по CAP теореме)? Вижу, что через ошибку протекает абстракция шардов до клиентов.

    2. Вызывающий AddItems,если не удалось, записать тоже ретраит? С дедлайном ли это делает? Допустим он читает с Kafka и пишет в базу, часть данных записалось, часть нет - что при этом он делает с сообщением - commit или dead letter queue?

    3. Есть ли какие-либо ограничения на вызовы со стороны клиента, типа "максимальное количество затрагиваемых шардов". Например, клиентский запрос может содержать ID со всех шардов, таким образом он может формировать request-ы так, что capacity всей шардированной базы будет равно capacity одного шарда. Т.е. допустим 1 шард держит нагрузку 10K RPS, и шардов 32 штуки, при этом клиент в request пихает 32 ID с каждого шардого по 1 ID, и делает 10K RPS => он положил все ваши 32 шарда. Требования к устойчивости к нагрузке не выполнено.


    1. kirill_petrov
      20.12.2022 17:46

      Еще парадокс, лучше не показывайте это сообщение менеджерам =)

      Допустим железка из под шарда стоит 10k$, и максимум может обслуживать 10k RPS.

      Таким образом цена обслуживания 1 RPS == 1$, однако если клиент формирует запрос, который касается нескольких шардов, то стоимость обслуживания будет 1 RPS == N$, где N, число затрагиваемых шардов.

      В итоге получается платим больше, но держим RPS столько же ;)


      UPD. Здесь разумеется просто шутка - если оценивать не в RPS, а в объеме полученных данных, то в итоге получится, что шардированная база будет отдавать больше.


    1. worldbug Автор
      20.12.2022 18:37

      Спасибо за оценку.
      Код в статье это просто демо, ни в коем случае не продакшен. Протекающие абстракции в демо-коде допустимы, дабы не размывать суть примера чистым кодом.
      Понятное дело клиент который дергает GRPC метод не получает в случае ошибки sql`ный error. В проде если мы не получили данные с шарда на пакетный запрос, в зависимости от бизнес требований мы можем вернуть ответом

      1. Обозначить что не смогли получить данные по ID

      2. Вообще не обозначать никак ошибку при получении данных, прикинутся что такого ID не существует и вернуть пустое поле

      3. Обозначить что при попытке получения данных по ID мы получили ошибку

      По поводу ретраев и ограничений, у нас собственный фреймворк scratch (можете найти доклады с конференций о нем, даже когда то Авито вдохновились и сделали свой scratch). Там встроенная поддержка ретраев, рейтлимитов и CircuitBreaker`ов. На стороне приложения надо просто настроить, далее интерфейсный слой сделает все за вас.
      То же самое с точки зрения Storage, в нашей платформенной библиотеке для работы с sql базами есть встроенный рейтлимимтер, который на уровне sql дравйвера не дает приложению по ошибке заDDoSить базу.
      Тут с точки зрения архитектуры я бы придерживался позиции что сервисный слой не должен думать о том что он может положить базу, пусть об этом забоится sql драйвер, в крайнем случае верхнеуровневая имплементация интерфейса storage / repository.