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


57

Я пишу схему для простой банковской базы данных. Вот основные характеристики:

  • База данных будет хранить транзакции против пользователя и валюты.
  • У каждого пользователя есть один баланс на валюту, поэтому каждый баланс - это просто сумма всех транзакций с данным пользователем и валютой.
  • Баланс не может быть отрицательным.

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

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

Мои варианты:

  1. Создайте отдельную balancesтаблицу и выполните одно из следующих действий:

    1. Применить операции для обоих transactionsи balancesтаблиц. Используйте TRANSACTIONлогику в слое моей хранимой процедуры, чтобы гарантировать, что сальдо и транзакции всегда синхронизированы. (При поддержке Джек .)

    2. Примените транзакции к transactionsтаблице и получите триггер, который обновит balancesдля меня таблицу с суммой транзакции.

    3. Примените транзакции к balancesтаблице и получите триггер, который добавляет новую запись в transactionsтаблицу для меня с суммой транзакции.

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

  2. Иметь balancesиндексированное представление, которое соответствующим образом агрегирует транзакции. Механизмы хранения гарантируют, что сальдо будет синхронизировано с их транзакциями, поэтому мне не нужно полагаться на подходы, основанные на безопасности, чтобы гарантировать это. С другой стороны, я больше не могу принудить баланс быть неотрицательным, так как представления - даже индексированные представления - не могут иметь CHECKограничений. (При поддержке Денни .)

  3. Имейте только transactionsтаблицу, но с дополнительным столбцом для хранения баланса, действующего сразу после выполнения этой транзакции. Таким образом, последняя запись транзакции для пользователя и валюты также содержит их текущий баланс. ( Предложено Эндрю ниже ; вариант предложен Гариком .)

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

  • Вы разработали или управляли такой базой данных с высокой нагрузкой? Каково было ваше решение этой проблемы?

  • Как вы думаете, я сделал правильный выбор дизайна? Что я должен иметь в виду?

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

  • Если индексированное представление - это путь, как я могу гарантировать отсутствие отрицательного баланса?


Архивация транзакций:

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

Так, например, этот список транзакций:

user_id    currency_id      amount    is_summary
------------------------------------------------
      3              1       10.60             0
      3              1      -55.00             0
      3              1      -12.12             0

архивируется и заменяется этим:

user_id    currency_id      amount    is_summary
------------------------------------------------
      3              1      -56.52             1

Таким образом, баланс с архивированными транзакциями поддерживает полную и непротиворечивую историю транзакций.


1
Если вы выберете вариант 2 (который я считаю более чистым), посмотрите на pgcon.org/2008/schedule/attachments/…, как эффективно реализовать «материализованные представления». Для варианта 1 глава 11 « Прикладной математики Хаана и Коппелаара для специалистов по базам данных» (не беспокойтесь о названии) была бы полезной, чтобы получить представление о том, как эффективно реализовать «переходные ограничения». Первая ссылка предназначена для PostgreSQL, а вторая - для Oracle, но методы должны работать для любой разумной системы баз данных.
JP

Теоретически вы хотите сделать № 3. Правильный способ сделать «текущий баланс» - это назначить баланс для каждой транзакции. Убедитесь, что вы можете заказать транзакции окончательно с серийным идентификатором (предпочтительно) или отметкой времени. Вы действительно не должны «вычислять» текущий баланс.
pbreitenbach

Ответы:


15

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

Вычисление промежуточных итогов печально известно медленно, независимо от того, делаете ли вы это с помощью курсора или треугольного соединения. Очень заманчиво денормализовать, хранить промежуточные итоги в столбце, особенно если вы часто его выбираете. Однако, как обычно, когда вы денормализуете, вы должны гарантировать целостность ваших денормализованных данных. К счастью, вы можете гарантировать целостность промежуточных итогов с ограничениями - если все ваши ограничения являются доверенными, все промежуточные итоги верны. Таким образом, вы также можете легко гарантировать, что текущий баланс (промежуточные итоги) никогда не будет отрицательным - применение других методов также может быть очень медленным. Следующий скрипт демонстрирует технику.

CREATE TABLE Data.Inventory(InventoryID INT NOT NULL IDENTITY,
  ItemID INT NOT NULL,
  ChangeDate DATETIME NOT NULL,
  ChangeQty INT NOT NULL,
  TotalQty INT NOT NULL,
  PreviousChangeDate DATETIME NULL,
  PreviousTotalQty INT NULL,
  CONSTRAINT PK_Inventory PRIMARY KEY(ItemID, ChangeDate),
  CONSTRAINT UNQ_Inventory UNIQUE(ItemID, ChangeDate, TotalQty),
  CONSTRAINT UNQ_Inventory_Previous_Columns 
     UNIQUE(ItemID, PreviousChangeDate, PreviousTotalQty),
  CONSTRAINT FK_Inventory_Self FOREIGN KEY(ItemID, PreviousChangeDate, PreviousTotalQty)
    REFERENCES Data.Inventory(ItemID, ChangeDate, TotalQty),
  CONSTRAINT CHK_Inventory_Valid_TotalQty CHECK(
         TotalQty >= 0 
     AND (TotalQty = COALESCE(PreviousTotalQty, 0) + ChangeQty)
  ),
  CONSTRAINT CHK_Inventory_Valid_Dates_Sequence CHECK(PreviousChangeDate < ChangeDate),
  CONSTRAINT CHK_Inventory_Valid_Previous_Columns CHECK(
        (PreviousChangeDate IS NULL AND PreviousTotalQty IS NULL)
     OR (PreviousChangeDate IS NOT NULL AND PreviousTotalQty IS NOT NULL)
  )
);

-- beginning of inventory for item 1
INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)
VALUES(1, '20090101', 10, 10, NULL, NULL);

-- cannot begin the inventory for the second time for the same item 1
INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)
VALUES(1, '20090102', 10, 10, NULL, NULL);


Msg 2627, Level 14, State 1, Line 10

Violation of UNIQUE KEY constraint 'UNQ_Inventory_Previous_Columns'. 
Cannot insert duplicate key in object 'Data.Inventory'.

The statement has been terminated.


-- add more
DECLARE @ChangeQty INT;
SET @ChangeQty = 5;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20090103', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

SET @ChangeQty = 3;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20090104', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

SET @ChangeQty = -4;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20090105', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

-- try to violate chronological order
SET @ChangeQty = 5;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20081231', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

Msg 547, Level 16, State 0, Line 4

The INSERT statement conflicted with the CHECK constraint 
"CHK_Inventory_Valid_Dates_Sequence". 
The conflict occurred in database "Test", table "Data.Inventory".

The statement has been terminated.

SELECT ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty
FROM Data.Inventory ORDER BY ChangeDate;

ChangeDate              ChangeQty   TotalQty    PreviousChangeDate      PreviousTotalQty
----------------------- ----------- ----------- ----------------------- -----
2009-01-01 00:00:00.000 10          10          NULL                    NULL
2009-01-03 00:00:00.000 5           15          2009-01-01 00:00:00.000 10
2009-01-04 00:00:00.000 3           18          2009-01-03 00:00:00.000 15
2009-01-05 00:00:00.000 -4          14          2009-01-04 00:00:00.000 18


-- try to change a single row, all updates must fail
UPDATE Data.Inventory SET ChangeQty = ChangeQty + 2 WHERE InventoryID = 3;
UPDATE Data.Inventory SET TotalQty = TotalQty + 2 WHERE InventoryID = 3;

-- try to delete not the last row, all deletes must fail
DELETE FROM Data.Inventory WHERE InventoryID = 1;
DELETE FROM Data.Inventory WHERE InventoryID = 3;

-- the right way to update
DECLARE @IncreaseQty INT;

SET @IncreaseQty = 2;

UPDATE Data.Inventory 
SET 
     ChangeQty = ChangeQty 
   + CASE 
        WHEN ItemID = 1 AND ChangeDate = '20090103' 
        THEN @IncreaseQty 
        ELSE 0 
     END,
  TotalQty = TotalQty + @IncreaseQty,
  PreviousTotalQty = PreviousTotalQty + 
     CASE 
        WHEN ItemID = 1 AND ChangeDate = '20090103' 
        THEN 0 
        ELSE @IncreaseQty 
     END
WHERE ItemID = 1 AND ChangeDate >= '20090103';

SELECT ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty
FROM Data.Inventory ORDER BY ChangeDate;

ChangeDate              ChangeQty   TotalQty    PreviousChangeDate      PreviousTotalQty
----------------------- ----------- ----------- ----------------------- ----------------
2009-01-01 00:00:00.000 10          10          NULL                    NULL
2009-01-03 00:00:00.000 7           17          2009-01-01 00:00:00.000 10
2009-01-04 00:00:00.000 3           20          2009-01-03 00:00:00.000 17
2009-01-05 00:00:00.000 -4          16          2009-01-04 00:00:00.000 20

14

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

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


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

Что вы думаете о комментариях Джека о том, что использование двух таблиц дает разработчикам большую гибкость в изменении или реализации бизнес-логики? Кроме того, есть ли у вас какой-либо непосредственный опыт работы с индексированными представлениями, который проверяет или решает эти проблемы ?
Ник Чаммас

1
Я бы не сказал, что наличие двух таблиц, обеспечивающих гибкость перемещения, является реализацией бизнес-логики. Это дает вам больше гибкости при выполнении архивирования данных. Однако, как банк (по крайней мере, в США), у вас есть законы, в которых указано, сколько данных вам нужно хранить. Вы захотите проверить, как выглядит производительность с представлением сверху, а также принять во внимание, что если у вас есть индексированное представление, вы не можете изменить схему базовых таблиц. Просто еще одна вещь для размышления.
Мрденни

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

1
Для пояснения: IMO транзакционный API обеспечивает большую гибкость при реализации бизнес-логики (не имея двух таблиц). В этом случае я бы также предпочел две таблицы (по крайней мере, учитывая информацию, которую мы имеем до сих пор) из-за компромиссов, предложенных с использованием подхода индексированного представления (например, не могу затем использовать DRI для обеспечения баланса> 0 бизнеса) правило)
Джек Дуглас

13

Немного другой подход (аналогичный вашему второму варианту), который нужно рассмотреть, состоит в том, чтобы иметь только таблицу транзакций с определением:

CREATE TABLE Transaction (
      UserID              INT
    , CurrencyID          INT 
    , TransactionDate     DATETIME  
    , OpeningBalance      MONEY
    , TransactionAmount   MONEY
);

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

Чтобы получить текущий баланс, все, что вам нужно, это последняя запись.

Способы получения последней записи :

/* For a single User/Currency */
Select TOP 1 *
FROM dbo.Transaction
WHERE UserID = 3 and CurrencyID = 1
ORDER By TransactionDate desc

/* For multiple records ie: to put into a view (which you might want to index) */
SELECT
    C.*
FROM
    (SELECT 
        *, 
        ROW_NUMBER() OVER (
           PARTITION BY UserID, CurrencyID 
           ORDER BY TransactionDate DESC
        ) AS rnBalance 
    FROM Transaction) C
WHERE
    C.rnBalance = 1
ORDER BY
    C.UserID, C.CurrencyID

Минусы:

  • При вставке транзакции из последовательности (т. Е. Для исправления проблемы / неправильного начального баланса) может потребоваться каскадное обновление для всех последующих транзакций.
  • Транзакции для пользователя / валюты должны быть сериализованы для поддержания точного баланса.

    -- Example of getting the current balance and locking the 
    -- last record for that User/Currency.
    -- This lock will be freed after the Stored Procedure completes.
    SELECT TOP 1 @OldBalance = OpeningBalance + TransactionAmount  
    FROM dbo.Transaction with (rowlock, xlock)   
    WHERE UserID = 3 and CurrencyID = 1  
    ORDER By TransactionDate DESC;
    

Плюсы:

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

Изменить: Некоторые примеры запросов на получение текущего баланса и выделить кон (Спасибо @Джек Дуглас)


3
Это SELECT TOP (1) ... ORDER BY TransactionDate DESCбудет очень сложно реализовать таким образом, что SQL Server не будет постоянно сканировать таблицу транзакций. Алексей Кузнецов разместил здесь решение похожей проблемы дизайна, которая отлично дополняет этот ответ.
Ник Чаммас

2
+1 Я использую похожий подход. Кстати, мы должны быть очень осторожны и убедиться, что наш код работает правильно при одновременной рабочей нагрузке.
AK

12

Прочитав эти два обсуждения, я выбрал вариант 2

Прочитав эти обсуждения, я не уверен, почему вы выбрали решение DRI, а не наиболее разумные из перечисленных вами вариантов:

Примените транзакции к таблицам транзакций и балансов. Используйте логику TRANSACTION в слое моей хранимой процедуры, чтобы гарантировать, что сальдо и транзакции всегда синхронизированы.

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

Я бы посоветовал использовать DRI, где это возможно, для обеспечения соблюдения бизнес-правил без чрезмерного изменения модели, чтобы сделать это возможным:

Даже если я архивирую транзакции (например, перемещая их в другое место и заменяя их сводными транзакциями)

Как только вы начнете рассматривать вопрос о загрязнении вашей модели таким образом, я думаю, что вы движетесь в область, где преимущества DRI перевешиваются трудностями, которые вы вводите. Рассмотрим, например, что ошибка в вашем процессе архивации может теоретически привести к тому, что ваше золотое правило (баланс всегда равен сумме транзакций) может молча нарушиться с помощью решения DRI .

Вот краткое изложение преимуществ транзакционного подхода, как я их вижу:

  • Мы должны делать это в любом случае, если это вообще возможно. Какое бы решение вы ни выбрали для этой конкретной проблемы, оно дает вам большую гибкость проектирования и контроль над вашими данными. Тогда весь доступ становится «транзакционным» с точки зрения бизнес-логики, а не только с точки зрения логики базы данных.
  • Вы можете держать свою модель в чистоте
  • Вы можете «обеспечить» гораздо более широкий диапазон и сложность бизнес-правил (отметив, что концепция «применения» является более слабой, чем в случае с DRI)
  • Вы по-прежнему можете использовать DRI везде, где это практически возможно, для придания модели более надежной целостности, и это может служить проверкой вашей транзакционной логики.
  • Большинство проблем с производительностью, которые беспокоят вас, исчезнут
  • Введение новых требований может быть намного проще - например: сложные правила для спорных транзакций могут вынудить вас отказаться от подхода чистого DRI в дальнейшем, что потребует значительных усилий.
  • Разделение или архивирование исторических данных становится гораздо менее рискованным и болезненным

--редактировать

Чтобы разрешить архивирование без добавления сложности или риска, вы можете хранить итоговые строки в отдельной сводной таблице, генерируемой непрерывно (заимствуя из @Andrew и @Garik)

Например, если резюме являются ежемесячными:

  • каждый раз, когда происходит транзакция (через ваш API), происходит соответствующее обновление или вставка в сводную таблицу
  • сводная таблица никогда не архивируется, но архивирование транзакций становится простым как удаление (или удаление раздела?)
  • каждая строка в сводной таблице включает в себя «начальное сальдо» и «сумма»
  • проверочные ограничения, такие как «начальное сальдо» + «сумма»> 0 и «начальное сальдо»> 0, могут применяться к сводной таблице
  • итоговые строки можно вставлять в ежемесячный пакет, чтобы упростить блокировку последней итоговой строки (для текущего месяца всегда будет строка)

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

Извините, что вы правы, что неясно - я имел в виду выдачу из таблицы балансов, так как это всегда было бы ключевым поиском в сводной таблице для получения текущего баланса (не соответствует предложению Эндрюса AFAIK). Преимущество состоит в том, что вычисление сальдо в предыдущие периоды становится проще, и существует более четкий контрольный журнал для сальдо, если они ошибаются.
Джек Дуглас

6

Ник.

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

 id   user_id    currency_id      amount    is_summary (or record_type)
----------------------------------------------------
  1       3              1       10.60             0
  2       3              1       10.60             1    -- summary after transaction 1
  3       3              1      -55.00             0
  4       3              1      -44.40             1    -- summary after transactions 1 and 3
  5       3              1      -12.12             0
  6       3              1      -56.52             1    -- summary after transactions 1, 3 and 5 

Лучшим вариантом является уменьшение количества кратких отчетов. У нас может быть одна запись баланса в конце (и / или начале) дня. Как вы знаете, каждый банк должен operational dayоткрыть, а затем закрыть его, чтобы выполнить некоторые сводные операции за этот день. Это позволяет нам легко рассчитывать проценты , используя ежедневную запись баланса, например:

user_id    currency_id      amount    is_summary    oper_date
--------------------------------------------------------------
      3              1       10.60             0    01/01/2011 
      3              1      -55.00             0    01/01/2011
      3              1      -44.40             1    01/01/2011 -- summary at the end of day (01/01/2011)
      3              1      -12.12             0    01/02/2011
      3              1      -56.52             1    01/02/2011 -- summary at the end of day (01/02/2011)

Удача.


4

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

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

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


3

В Oracle вы можете сделать это, используя только таблицу транзакций с быстро обновляемым Материализованным представлением, которое выполняет агрегирование для формирования баланса. Вы определяете триггер в материализованном представлении. Если материализованное представление определено с помощью «ON COMMIT», оно эффективно предотвращает добавление / изменение данных в базовых таблицах. Триггер обнаруживает [in] допустимые данные и вызывает исключение, когда он откатывает транзакцию. Хороший пример здесь http://www.sqlsnippets.com/en/topic-12896.html

Я не знаю sqlserver, но, возможно, у него есть аналогичная опция?


2
Материализованные представления в Oracle похожи на «индексированное представление» SQL Server, но они обновляются автоматически, а не явно управляемым способом, таким как поведение Oracle «ON COMMIT». См. Social.msdn.microsoft.com/Forums/fi-FI/transactsql/thread/… и techembassy.blogspot.com/2007/01/…
GregW
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.