SUM з DATALENGTH не відповідає розміру таблиці від sys.allocation_units


11

У мене було враження, що якби я підсумував DATALENGTH()усі поля для всіх записів у таблиці, я отримав би загальний розмір таблиці. Я помиляюся?

SELECT 
SUM(DATALENGTH(Field1)) + 
SUM(DATALENGTH(Field2)) + 
SUM(DATALENGTH(Field3)) TotalSizeInBytes
FROM SomeTable
WHERE X, Y, and Z are true

Я використовував цей запит нижче (що я отримав від Інтернету, щоб отримати розміри таблиць, тільки кластерні індекси, щоб він не включав індекси NC), щоб отримати розмір певної таблиці в моїй базі даних. Для цілей виставлення рахунків (ми оплачуємо наші відділи за кількістю місця, яке вони використовують), мені потрібно розібратися, скільки місця в цьому таблиці використовує кожен відділ. У мене є запит, який ідентифікує кожну групу в таблиці. Мені просто потрібно розібратися, скільки місця займає кожна група.

Простір в ряд може дико коливатися через VARCHAR(MAX)поля в таблиці, тому я не можу просто взяти середній розмір * співвідношення рядків для відділу. Коли я використовую DATALENGTH()описаний вище підхід, я отримую лише 85% всього простору, що використовується у наведеному нижче запиті. Думки?

SELECT 
s.Name AS SchemaName,
t.NAME AS TableName,
p.rows AS RowCounts,
(SUM(a.total_pages) * 8)/1024 AS TotalSpaceMB, 
(SUM(a.used_pages) * 8)/1024 AS UsedSpaceMB, 
((SUM(a.total_pages) - SUM(a.used_pages)) * 8)/1024 AS UnusedSpaceMB
FROM 
    sys.tables t with (nolock)
INNER JOIN 
    sys.schemas s with (nolock) ON s.schema_id = t.schema_id
INNER JOIN      
    sys.indexes i with (nolock) ON t.OBJECT_ID = i.object_id
INNER JOIN 
    sys.partitions p with (nolock) ON i.object_id = p.OBJECT_ID AND i.index_id = p.index_id
INNER JOIN 
    sys.allocation_units a with (nolock) ON p.partition_id = a.container_id
WHERE 
    t.is_ms_shipped = 0
    AND i.OBJECT_ID > 255 
    AND i.type_desc = 'Clustered'
GROUP BY 
    t.Name, s.Name, p.Rows
ORDER BY 
    TotalSpaceMB desc

Було запропоновано створити відфільтрований індекс для кожного відділу або розділу таблиці, тому я можу безпосередньо запитувати простір, що використовується на індекс. Фільтровані індекси можна було б створити програмно (і знову скинути під час вікна технічного обслуговування або коли мені потрібно періодично виставляти рахунки), замість того, щоб весь час використовувати простір (розділи були б кращими в цьому відношенні).

Мені подобається ця пропозиція і зазвичай це роблю. Але якщо чесно, я використовую "кожну кафедру" як приклад, щоб пояснити, для чого мені це потрібно, але якщо чесно, це не дуже. Через причини конфіденційності я не можу пояснити точну причину, чому мені потрібні ці дані, але це аналогічно різним підрозділам.

Щодо некластеризованих індексів цієї таблиці: Якщо я можу отримати розміри індексів NC, це було б чудово. Однак індекси NC складають <1% від розміру кластеризованого індексу, тому ми все нормально, не враховуючи цих. Однак як би ми все-таки включили індекси NC? Я навіть не можу отримати точний розмір для індексу Clustered :)


Отже, по суті, у вас є два питання: (1) чому сума довжин рядків не відповідає обліку метаданих розміру всієї таблиці? У нижченаведеній відповіді йдеться про те, що хоча б частково (і що може коливатися за випуск та за функцією, наприклад, стиснення, зберігання стовпців тощо). І що ще важливіше: (2) як можна точно визначити фактичний простір, що використовується у відділі? Я не знаю, що ви можете це зробити точно - адже для деяких даних, що враховуються у відповіді, немає способу сказати, до якого відділу він належить.
Аарон Бертран

Я не думаю, що проблема полягає в тому, що у вас немає точного розміру для кластерного індексу - метадані, безумовно, точно говорять вам, скільки місця займає ваш індекс. Що метадані не розроблені для того, щоб вам сказати - принаймні з огляду на ваш сучасний дизайн / структуру - скільки даних пов’язано з кожним відділом.
Аарон Бертран

Відповіді:


19

                          Please note that the following info is not intended to be a comprehensive
description of how data pages are laid out, such that one can calculate
the number of bytes used per any set of rows, as that is very complicated.

Дані - не єдине, що займає місце на сторінці даних 8K:

  • Є зарезервований простір. Ви можете використовувати лише 8060 з 8192 байт (це 132 байти, які ніколи не були вашими):

    • Заголовок сторінки: це рівно 96 байт.
    • Масив слотів: це 2 байти на рядок і вказує на зміщення місця початку кожного рядка на сторінці. Розмір цього масиву не обмежується іншими 36 байтами (132 - 96 = 36), інакше ви будете фактично обмежені лише розміщенням максимум 18 рядків на сторінці даних. Це означає, що кожен рядок на 2 байти більший, ніж ви думаєте. Це значення не включається до "розміру запису", як повідомляється DBCC PAGE, тому воно зберігається окремо тут, замість того, щоб його включати в інформацію про рядки нижче.
    • Мета-дані на рядок (включаючи, але не обмежуючись ними):
      • Розмір змінюється залежно від визначення таблиці (тобто кількості стовпців, змінної довжини або фіксованої довжини тощо). Інформація, взята з коментарів @ PaulWhite та @ Aaron, які можна знайти в дискусії, що стосується цієї відповіді та тестування.
      • Рядок-заголовок: 4 байти, 2 з яких позначають тип запису, а два інших - зміщення до растрової карти NULL
      • Кількість стовпців: 2 байти
      • NULL Bitmap: які стовпці зараз є NULL. 1 байт на кожен набір із 8 стовпців. І для всіх стовпців, навіть NOT NULLтих. Значить, мінімум 1 байт.
      • Масив зміщення стовпців змінної довжини: мінімум 4 байти. 2 байти, щоб вмістити кількість стовпців змінної довжини, а потім 2 байти на кожен стовпчик змінної довжини, щоб утримувати зміщення до місця, де воно починається.
      • Інформація про версію: 14 байт (це буде наявно, якщо для вашої бази даних встановлено ALLOW_SNAPSHOT_ISOLATION ONабо READ_COMMITTED_SNAPSHOT ON).
    • Будь ласка, перегляньте наступне запитання та відповіді, щоб отримати детальну інформацію про це: Масив слотів та загальний розмір сторінки
    • Перегляньте наступну публікацію в блозі від Пола Рендала, в якій є кілька цікавих подробиць про те, як розміщені сторінки даних: Познайомлення з DBCC PAGE (Частина 1?)
  • Покажчики LOB для даних, які не зберігаються у рядку. Отже, це складе DATALENGTH+ pointer_size. Але вони не стандартного розміру. Детальну інформацію про цю складну тему див. У наступному блозі: Який розмір покажчика LOB для (MAX) типів, таких як Varchar, Varbinary, Etc? . Між цією пов’язаною публікацією та деякими додатковими тестуваннями, які я зробив , правила (за замовчуванням) повинні бути такими:

    • Спадщина / засуджується типи LOB , що ніхто не повинен використовувати більше від SQL Server 2005 ( TEXT, NTEXTі IMAGE):
      • За замовчуванням завжди зберігайте свої дані на сторінках LOB і завжди використовуйте 16-байтовий покажчик на сховище LOB.
      • Якщо для встановлення параметра використовується параметр sp_tableoptiontext in row , тоді:
        • якщо на сторінці є простір для зберігання значення, а значення не більше максимального розміру рядка (діапазон, який можна налаштувати 24 - 7000 байт за замовчуванням 256), він буде зберігатися в рядку,
        • інакше це буде 16-байтовий покажчик.
    • Для нових типів великих об'єктів , введених в SQL Server 2005 ( VARCHAR(MAX), NVARCHAR(MAX)і VARBINARY(MAX)):
      • За замовчуванням:
        • Якщо значення не перевищує 8000 байт, а на сторінці є місце, то воно буде зберігатися в рядку.
        • Вбудований корінь - для даних між 8001 і 40 000 (дійсно 42 000) байтів, дозволяючи простір, буде 1 - 5 покажчиків (24 - 72 байти) В РАДКІ, що вказують безпосередньо на сторінку LOB. 24 байти для початкової сторінки з 8 кб LOB і 12 байт на кожну додаткову 8 к сторінок до ще чотирьох 8 к сторінок.
        • TEXT_TREE - для даних, що перевищують 42 000 байт, або якщо від 1 до 5 покажчиків не може вписатись в рядок, тоді буде лише 24-байтовий покажчик на початкову сторінку списку покажчиків на сторінки LOB (тобто "text_tree" "сторінка).
      • Якщо для встановлення параметра використовувалася sp_tableoptionlarge value types out of row , то завжди використовуйте 16-байтовий вказівник на сховище LOB.
    • Я сказав "правила за замовчуванням", оскільки не перевіряв значення рядків на вплив певних функцій, таких як стиснення даних, шифрування на рівні стовпців, прозоре шифрування даних, завжди зашифрований тощо.
  • Сторінки переповнення LOB: Якщо значення дорівнює 10k, то для цього знадобиться 1 повна 8k сторінка переповнення, а потім частина 2-ї сторінки. Якщо жодна інша інформація не може займати залишковий простір (або це навіть дозволено, я не впевнений у цьому правилі), то у вас є приблизно 6 кбіт "витраченого" простору на цій другій сторінці переповнення LOB.

  • Невикористаний простір. Сторінка даних 8k - це лише те: 8192 байт. Він не відрізняється за розміром. Дані та метадані, розміщені на них, однак, не завжди добре вписуються у всі 8192 байти. І рядки не можна розділити на кілька сторінок даних. Отже, якщо у вас залишилося 100 байтів, але жоден рядок (або жодна рядок, який би розміщувався в цьому місці, залежно від кількох факторів), не може вміститись там, сторінка даних все ще займає 8192 байти, а ваш другий запит підраховує лише кількість сторінки даних. Ви можете знайти це значення у двох місцях (лише майте на увазі, що деяка частина цього значення - це деяка кількість зарезервованого простору):

    • DBCC PAGE( db_name, file_id, page_id ) WITH TABLERESULTS;Шукайте ParentObject= "PAGE HEADER:" і Field= "m_freeCnt". ValueПоле число невикористаних байтів.
    • SELECT buff.free_space_in_bytes FROM sys.dm_os_buffer_descriptors buff WHERE buff.[database_id] = DB_ID(N'db_name') AND buff.[page_id] = page_id;Це те саме значення, що повідомляється "m_freeCnt". Це простіше, ніж DBCC, оскільки він може отримати багато сторінок, але також вимагає, щоб сторінки були прочитані в буферному пулі в першу чергу.
  • Простір, зарезервований FILLFACTOR<100. Новостворені сторінки не враховують FILLFACTORналаштування, але виконати REBUILD зарезервує це місце на кожній сторінці даних. Ідея зарезервованого простору полягає в тому, що він буде використовуватися непослідовними вставками та / або оновленнями, які вже збільшують розмір рядків на сторінці, через те, що стовпці змінної довжини оновлюються трохи більше даних (але недостатньо, щоб викликати розділення сторінки). Але ви можете легко зарезервувати простір на сторінках даних, які, природно, ніколи не отримуватимуть нові рядки і ніколи не оновлюватимуть існуючі рядки або принаймні не оновлюватимуться таким чином, що збільшуватиме розмір рядка.

  • Розділення сторінок (фрагментація): Необхідність додавання рядка до місця, де немає місця для рядка, спричинить розкол сторінки. У цьому випадку приблизно 50% існуючих даних переміщуються на нову сторінку, а новий рядок додається до однієї з 2 сторінок. Але тепер у вас є трохи більше вільного місця, яке не враховується DATALENGTHпідрахунками.

  • Рядки, позначені для видалення. Видаляючи рядки, вони не завжди одразу видаляються зі сторінки даних. Якщо їх не вдасться зняти негайно, вони будуть "позначені смертю" (посилання Стівена Сегала) і пізніше будуть фізично видалені шляхом прибирання привидів (я вважаю, що це назва). Однак це може не стосуватися цього конкретного питання.

  • Сторінки привидів? Не впевнений, чи це правильний термін, але іноді сторінки даних не видаляються, поки не буде виконано ПОВТОРЕННЯ кластерного індексу. Це також враховує більше сторінок, ніж DATALENGTHдо них. Це взагалі не повинно статися, але я натрапив на це один раз, кілька років тому.

  • РІЗНАЧНІ стовпці: Рідкі стовпці економлять простір (переважно для типів даних фіксованої довжини) у таблицях, де великий% рядків припадає NULLна один або кілька стовпців. SPARSEВаріант робить NULLтип значення до 0 байт (замість нормального кількості фіксованою довжиною, наприклад , як 4 байта для INT), але , що не нульових значень кожного займає ще 4 байта для типів фіксованої довжини і кількості змінного для типи змінної довжини. Проблема тут полягає в тому, DATALENGTHщо не включаються зайві 4 байти для значень, що не мають NULL, у стовпці SPARSE, тому ці 4 байти потрібно додати ще раз. Ви можете перевірити, чи є SPARSEколонки через:

    SELECT OBJECT_SCHEMA_NAME(sc.[object_id]) AS [SchemaName],
           OBJECT_NAME(sc.[object_id]) AS [TableName],
           sc.name AS [ColumnName]
    FROM   sys.columns sc
    WHERE  sc.is_sparse = 1;
    

    А потім для кожного SPARSEстовпця оновіть початковий запит, щоб використовувати:

    SUM(DATALENGTH(FieldN) + 4)

    Зауважте, що вищевказаний розрахунок для додавання в стандартні 4 байти трохи спрощений, оскільки працює лише для типів фіксованої довжини. І, є додаткові метадані на рядок (з того, що я можу сказати досі), що зменшує доступний для даних простір, просто маючи принаймні один стовпець SPARSE. Для отримання більш детальної інформації див. Сторінку MSDN для використання Розріджених стовпців .

  • Індексні та інші (наприклад, IAM, PFS, GAM, SGAM тощо): це не "сторінки даних" з точки зору даних користувачів. Вони збільшать загальний розмір столу. Якщо ви використовуєте SQL Server 2012 або новішу sys.dm_db_database_page_allocationsверсію , ви можете використовувати функцію динамічного управління (DMF), щоб переглянути типи сторінок (можна використовувати більш ранні версії SQL Server DBCC IND(0, N'dbo.table_name', 0);):

    SELECT *
    FROM   sys.dm_db_database_page_allocations(
                   DB_ID(),
                   OBJECT_ID(N'dbo.table_name'),
                   1,
                   NULL,
                   N'DETAILED'
                  )
    WHERE  page_type = 1; -- DATA_PAGE
    

    Ні DBCC INDані норми sys.dm_db_database_page_allocations(з цим пунктом WHERE) не повідомлятимуть про будь-які сторінки індексу, і лише DBCC INDповідомлення повідомлятиме про щонайменше одну сторінку IAM.

  • DATA_COMPRESSION: Якщо у вас ввімкнено ROWабо PAGEстиснення в кластерному індексі або купі, ви можете забути про більшість згаданих досі. 96-байтний заголовок сторінок, 2 байти на рядок слотового масиву та 14 байт на рядок інформація про версію все ще є, але фізичне представлення даних стає дуже складним (набагато більше, ніж те, що вже згадувалося при стисненні не використовується). Наприклад, за допомогою стиснення рядків SQL Server намагається використати найменший контейнер для розміщення кожного стовпця в кожному рядку. Отже, якщо у вас є BIGINTстовпець, який інакше (якщо SPARSEтакож не включено) завжди займає 8 байт, якщо значення становить від -128 до 127 (тобто підписане 8-бітове ціле число), то він буде використовувати лише 1 байт, і якщо значення може вписатися вSMALLINT, він займе лише 2 байти. Цілі типи, які є NULLабо 0не займають місця і просто вказуються як NULL"порожні" (тобто 0) у масиві, що відображає стовпці. І є багато-багато інших правил. Чи є дані Unicode ( NCHAR, NVARCHAR(1 - 4000)але ні NVARCHAR(MAX) , навіть якщо вони зберігаються в рядку)? Стиснення Unicode було додано в SQL Server 2008 R2, але неможливо передбачити результат "стисненого" значення у всіх ситуаціях, не роблячи фактичного стиснення, враховуючи складність правил .

Тож справді, ваш другий запит, хоча більш точний з точки зору загального фізичного простору, зайнятого на диску, є дійсно точним лише при виконанні REBUILDкластерного індексу. А після цього вам все одно потрібно враховувати будь-які FILLFACTORналаштування нижче 100. І навіть тоді завжди є заголовки сторінок, і часто досить деякої кількості "витраченого" простору, який просто не можна заповнити через те, що він занадто малий, щоб вмістити будь-який рядок у цьому таблиці або, принаймні, рядок, який логічно повинен містити цей слот.

Що стосується точності 2-го запиту при визначенні "використання даних", то найбільш справедливим є резервне копіювання байтів заголовка сторінки, оскільки вони не є використанням даних: вони є витратами на витрату бізнесу. Якщо на сторінці даних є 1 рядок, а цей рядок є лише a TINYINT, тоді для байта даних потрібен 1 байт і, отже, 96 байт заголовка. Чи повинен за цей 1 відділ стягуватися плата за всю сторінку даних? Якщо цю сторінку даних заповнити Департамент №2, чи розподілили б вони рівномірно цю "накладну" вартість або сплатили пропорційно? Здається, найпростіше просто відмовитись. У такому випадку використання значення 8множення на проти number of pagesзанадто високе. Як на рахунок:

-- 8192 byte data page - 96 byte header = 8096 (approx) usable bytes.
SELECT 8060.0 / 1024 -- 7.906250

Отже, використовуйте щось на кшталт:

(SUM(a.total_pages) * 7.91) / 1024 AS [TotalSpaceMB]

для всіх обчислень у стовпцях "number_of_pages".

І , враховуючи, що використання DATALENGTHкожного поля не може повернути метадані на рядки, їх слід додати до запиту DATALENGTHна кожну таблицю, де ви отримуєте кожне поле, фільтруючи по кожному "відділу":

  • Тип запису та зміщення до NULL растрової карти: 4 байти
  • Кількість стовпців: 2 байти
  • Масив слотів: 2 байти (не входять до "розміру запису", але все ще потрібно враховувати)
  • Растровий файл NULL: 1 байт на кожні 8 стовпців (для всіх стовпців)
  • Версія версії: 14 байт (якщо база даних має ALLOW_SNAPSHOT_ISOLATIONабо READ_COMMITTED_SNAPSHOTвстановлено ON)
  • Стовпець зміщеної довжини Масив зміщення: 0 байт, якщо всі стовпці фіксованої довжини. Якщо будь-які стовпці мають змінну довжину, то 2 байти плюс 2 байти на кожен із стовпців змінної довжини.
  • Покажчики LOB: ця частина дуже неточна, оскільки не буде вказівника, якщо значення є NULL, і якщо значення підходить до рядка, то воно може бути набагато меншим або значно більшим, ніж покажчик, і якщо значення зберігається не- рядок, то розмір вказівника може залежати від кількості даних. Однак, оскільки ми просто хочемо оцінювати (тобто "swag"), схоже, 24 байти - це корисне значення для використання (ну, так само добре, як і будь-який інший ;-). Це для кожного MAXполя.

Отже, використовуйте щось на кшталт:

  • Загалом (заголовок рядка + кількість стовпців + масив слотів + ​​NULL растрова карта):

    ([RowCount] * (( 4 + 2 + 2 + (1 + (({NumColumns} - 1) / 8) ))
  • Загалом (автоматично виявляє, якщо є "інформація про версію"):

    + (SELECT CASE WHEN snapshot_isolation_state = 1 OR is_read_committed_snapshot_on = 1
                     THEN 14 ELSE 0 END FROM sys.databases WHERE [database_id] = DB_ID())
    
  • Якщо є стовпчики змінної довжини, то додайте:

    + 2 + (2 * {NumVariableLengthColumns})
  • Якщо є MAXколони / LOB, додайте:

    + (24 * {NumLobColumns})
  • В загальному:

    )) AS [MetaDataBytes]

Це не точно, і знову не вийде, якщо у вас ввімкнено стиснення рядків або сторінок у Heap або Clustered Index, але, безумовно, слід наблизити вас.


ОНОВЛЕННЯ щодо таємниці різниці 15%

Ми (включаючи мене) були настільки зосереджені на думці про те, як розкладені сторінки даних і як DATALENGTHможна пояснити речі, на які ми не витратили багато часу на перегляд 2-го запиту. Я провів цей запит по одній таблиці, а потім порівняв ці значення з тим, про що повідомлялося, sys.dm_db_database_page_allocationsі вони не були однаковими для кількості сторінок. Під час перегляду я видалив сукупні функції GROUP BYі замінив SELECTсписок на a.*, '---' AS [---], p.*. І тоді стало зрозуміло: люди повинні бути обережними, звідки на цих мутних переплетеннях вони отримують інформацію та сценарії ;-). 2-й запит, розміщений у Питання, не зовсім коректний, особливо для цього конкретного питання.

  • Незначна проблема: поза нею не має великого сенсу GROUP BY rows(і немає цього стовпця в сукупній функції), ПРИЄДНАЙТЕСЬ між sys.allocation_unitsі sys.partitionsтехнічно не є правильним. Існує 3 типи одиниць розподілу, і один з них повинен приєднатися до іншого поля. Досить часто partition_idі hobt_idтой же, так що не може бути проблемою, але іноді ці два поля мають різні значення.

  • Основна проблема: запит використовує used_pagesполе. Це поле охоплює всі типи сторінок: Дані, Індекс, IAM тощо, тс. Існує ще один, більш відповідне поле для використання при стосується тільки фактичні дані: data_pages.

Я адаптував 2-й запит у запитанні з урахуванням вищезазначених пунктів і, використовуючи розмір сторінки даних, що резервує заголовок сторінки. Я також видалив два JOIN і що НЕ було необхідності: sys.schemas(замінена на виклик SCHEMA_NAME()), і sys.indexes(індекс кластерного завжди , index_id = 1і ми маємо index_idв sys.partitions).

SELECT  SCHEMA_NAME(st.[schema_id]) AS [SchemaName],
        st.[name] AS [TableName],
        SUM(sp.[rows]) AS [RowCount],
        (SUM(sau.[total_pages]) * 8.0) / 1024 AS [TotalSpaceMB],
        (SUM(CASE sau.[type]
           WHEN 1 THEN sau.[data_pages]
           ELSE (sau.[used_pages] - 1) -- back out the IAM page
         END) * 7.91) / 1024 AS [TotalActualDataMB]
FROM        sys.tables st
INNER JOIN  sys.partitions sp
        ON  sp.[object_id] = st.[object_id]
INNER JOIN  sys.allocation_units sau
        ON  (   sau.[type] = 1
            AND sau.[container_id] = sp.[partition_id]) -- IN_ROW_DATA
        OR  (   sau.[type] = 2
            AND sau.[container_id] = sp.[hobt_id]) -- LOB_DATA
        OR  (   sau.[type] = 3
            AND sau.[container_id] = sp.[partition_id]) -- ROW_OVERFLOW_DATA
WHERE       st.is_ms_shipped = 0
--AND         sp.[object_id] = OBJECT_ID(N'dbo.table_name')
AND         sp.[index_id] < 2 -- 1 = Clustered Index; 0 = Heap
GROUP BY    SCHEMA_NAME(st.[schema_id]), st.[name]
ORDER BY    [TotalSpaceMB] DESC;

Коментарі не для розширеного обговорення; ця розмова була переміщена до чату .
Пол Білий 9

Хоча оновлений запит, який ви надали для другого запиту, ще більше (в іншому напрямку зараз :)), я з цією відповіддю все в порядку. Це дуже жорсткий горіх, який мабуть зламається, і для чого він вартий, я радий, що навіть фахівці, які допомагають мені, мені ще не вдалося з'ясувати точну причину, що два способи не відповідають. Я збираюся просто використовувати методологію в іншій відповіді на екстраполяцію. Я хотів би, щоб я міг проголосувати "за" обидва ці відповіді, але @srutzky допомагав з усіх причин, через які вони будуть відхилені.
Кріс Вудс

6

Можливо, це відповідь гранж, але це я б робив.

Тож DATALENGTH становить лише 86% від загальної кількості. Це все ще дуже представницький розкол. Накладні витрати у чудовій відповіді від srutzky повинні мати досить рівномірний розкол.

Я б використав ваш другий запит (сторінки) для загального. І використовувати перший (довжина даних) для розподілу розбиття. Багато витрат розподіляється за допомогою нормалізації.

І вам доведеться розглянути більш детальну відповідь - це збільшить вартість, тому навіть департамент, який втратив на розкол, може все-таки заплатити більше.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.