Обробка одночасного доступу до ключової таблиці без тупиків у SQL Server


32

У мене є таблиця, яка використовується застарілим додатком як заміна IDENTITYполів у різних інших таблицях.

Кожен рядок таблиці зберігає останній використаний ідентифікатор LastIDдля поля, названого в IDName.

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

Я впевнений, що має бути спосіб отримати доступ до цієї таблиці без взагалі ніяких тупиків.

Сама база даних налаштована READ_COMMITTED_SNAPSHOT = 1.

По-перше, ось таблиця:

CREATE TABLE [dbo].[tblIDs](
    [IDListID] [int] NOT NULL 
        CONSTRAINT PK_tblIDs 
        PRIMARY KEY CLUSTERED 
        IDENTITY(1,1) ,
    [IDName] [nvarchar](255) NULL,
    [LastID] [int] NULL,
);

І некластеризований індекс на IDNameполі:

CREATE NONCLUSTERED INDEX [IX_tblIDs_IDName] 
ON [dbo].[tblIDs]
(
    [IDName] ASC
) 
WITH (
    PAD_INDEX = OFF
    , STATISTICS_NORECOMPUTE = OFF
    , SORT_IN_TEMPDB = OFF
    , DROP_EXISTING = OFF
    , ONLINE = OFF
    , ALLOW_ROW_LOCKS = ON
    , ALLOW_PAGE_LOCKS = ON
    , FILLFACTOR = 80
);

GO

Деякі приклади даних:

INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeTestID', 1);
INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeOtherTestID', 1);
GO

Збережена процедура, яка використовується для оновлення значень, збережених у таблиці, та повернення наступного ідентифікатора:

CREATE PROCEDURE [dbo].[GetNextID](
    @IDName nvarchar(255)
)
AS
BEGIN
    /*
        Description:    Increments and returns the LastID value from tblIDs
        for a given IDName
        Author:         Max Vernon
        Date:           2012-07-19
    */

    DECLARE @Retry int;
    DECLARE @EN int, @ES int, @ET int;
    SET @Retry = 5;
    DECLARE @NewID int;
    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    SET NOCOUNT ON;
    WHILE @Retry > 0
    BEGIN
        BEGIN TRY
            BEGIN TRANSACTION;
            SET @NewID = COALESCE((SELECT LastID 
                FROM tblIDs 
                WHERE IDName = @IDName),0)+1;
            IF (SELECT COUNT(IDName) 
                FROM tblIDs 
                WHERE IDName = @IDName) = 0 
                    INSERT INTO tblIDs (IDName, LastID) 
                    VALUES (@IDName, @NewID)
            ELSE
                UPDATE tblIDs 
                SET LastID = @NewID 
                WHERE IDName = @IDName;
            COMMIT TRANSACTION;
            SET @Retry = -2; /* no need to retry since the operation completed */
        END TRY
        BEGIN CATCH
            IF (ERROR_NUMBER() = 1205) /* DEADLOCK */
                SET @Retry = @Retry - 1;
            ELSE
                BEGIN
                SET @Retry = -1;
                SET @EN = ERROR_NUMBER();
                SET @ES = ERROR_SEVERITY();
                SET @ET = ERROR_STATE()
                RAISERROR (@EN,@ES,@ET);
                END
            ROLLBACK TRANSACTION;
        END CATCH
    END
    IF @Retry = 0 /* must have deadlock'd 5 times. */
    BEGIN
        SET @EN = 1205;
        SET @ES = 13;
        SET @ET = 1
        RAISERROR (@EN,@ES,@ET);
    END
    ELSE
        SELECT @NewID AS NewID;
END
GO

Зразок виконання збереженого файлу:

EXEC GetNextID 'SomeTestID';

NewID
2

EXEC GetNextID 'SomeTestID';

NewID
3

EXEC GetNextID 'SomeOtherTestID';

NewID
2

Редагувати:

Я додав новий індекс, оскільки існуючий індекс IX_tblIDs_Name SP не використовується; Я припускаю, що процесор запитів використовує кластерний індекс, оскільки йому потрібне значення, збережене в LastID. У будь-якому випадку, цей індекс IS використовується в реальному плані виконання:

CREATE NONCLUSTERED INDEX IX_tblIDs_IDName_LastID 
ON dbo.tblIDs
(
    IDName ASC
) 
INCLUDE
(
    LastID
)
WITH (FILLFACTOR = 100
    , ONLINE=ON
    , ALLOW_ROW_LOCKS = ON
    , ALLOW_PAGE_LOCKS = ON);

ЗРІД №2:

Я взяв поради, які @AaronBertrand дав і трохи змінив. Загальна ідея тут - вдосконалити заяву, щоб усунути непотрібне блокування та загалом зробити SP більш ефективним.

Код, наведений нижче, замінює цей код вище BEGIN TRANSACTIONна END TRANSACTION:

BEGIN TRANSACTION;
SET @NewID = COALESCE((SELECT LastID 
        FROM dbo.tblIDs 
        WHERE IDName = @IDName), 0) + 1;

IF @NewID = 1
    INSERT INTO tblIDs (IDName, LastID) 
    VALUES (@IDName, @NewID);
ELSE
    UPDATE dbo.tblIDs 
    SET LastID = @NewID 
    WHERE IDName = @IDName;

COMMIT TRANSACTION;

Оскільки наш код ніколи не додає запису до цієї таблиці з 0 у, LastIDми можемо зробити припущення, що якщо @NewID дорівнює 1, то намір додає новий ідентифікатор до списку, інакше ми оновлюємо існуючий рядок у списку.


Те, як ви налаштували базу даних для підтримки RCSI, не має значення. Ви навмисно переходите SERIALIZABLEсюди.
Аарон Бертран

так, я просто хотів додати всю релевантну інформацію. Я радий, що ти підтверджуєш, що це не має значення!
Макс Вернон

дуже просто зробити sp_getapplock стати жертвою тупикового зв'язку, але якщо не розпочати транзакцію, зателефонуйте sp_getapplock один раз, щоб придбати ексклюзивний замок, і продовжуйте свою модифікацію.
АК

1
Чи унікальний IDName? Потім рекомендуємо "створити унікальний некластерний індекс". Однак якщо вам потрібні нульові значення, то індекс також потрібно буде відфільтрувати .
крокусек

Відповіді:


15

По-перше, я б уникнув зворотної поїздки до бази даних для кожного значення. Наприклад, якщо ваша програма знає, що їй потрібно 20 нових посвідчень, не робіть 20 туди і назад. Зробіть лише один збережений виклик процедури та збільште лічильник на 20. Також може бути краще розділити вашу таблицю на кілька.

Можна взагалі уникнути тупиків. У мене в системі взагалі немає тупиків. Є кілька способів досягти цього. Я покажу, як би я використовував sp_getapplock для усунення тупиків. Я не маю уявлення, чи буде це працювати для вас, оскільки SQL Server є закритим вихідним кодом, тому я не бачу вихідного коду, і як такий я не знаю, чи перевірив я всі можливі випадки.

Далі описано, що працює для мене. YMMV.

Спочатку почнемо зі сценарію, коли ми завжди отримуємо значну кількість тупиків. По-друге, ми будемо використовувати sp_getapplock для їх усунення. Найважливішим моментом тут є стрес-тест вашого рішення. Ваше рішення може бути різним, але вам потрібно піддавати його великій одночасності, як я продемонструю пізніше.

Передумови

Давайте створимо таблицю з деякими тестовими даними:

CREATE TABLE dbo.Numbers(n INT NOT NULL PRIMARY KEY); 
GO 

INSERT INTO dbo.Numbers 
    ( n ) 
        VALUES  ( 1 ); 
GO 
DECLARE @i INT; 
    SET @i=0; 
WHILE @i<21  
    BEGIN 
    INSERT INTO dbo.Numbers 
        ( n ) 
        SELECT n + POWER(2, @i) 
        FROM dbo.Numbers; 
    SET @i = @i + 1; 
    END;  
GO

SELECT n AS ID, n AS Key1, n AS Key2, 0 AS Counter1, 0 AS Counter2
INTO dbo.DeadlockTest FROM dbo.Numbers
GO

ALTER TABLE dbo.DeadlockTest ADD CONSTRAINT PK_DeadlockTest PRIMARY KEY(ID);
GO

CREATE INDEX DeadlockTestKey1 ON dbo.DeadlockTest(Key1);
GO

CREATE INDEX DeadlockTestKey2 ON dbo.DeadlockTest(Key2);
GO

Наступні дві процедури, ймовірно, включають тупик:

CREATE PROCEDURE dbo.UpdateCounter1 @Key1 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
SET @Key1=@Key1-10000;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
COMMIT;
GO

CREATE PROCEDURE dbo.UpdateCounter2 @Key2 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
SET @Key2=@Key2-10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
SET @Key2=@Key2+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
COMMIT;
GO

Відтворення тупиків

Наступні петлі повинні відтворювати більше 20 тупиків кожного разу, коли ви запускаєте їх. Якщо ви отримаєте менше 20, збільште кількість ітерацій.

На одній вкладці запустіть це;

DECLARE @i INT, @DeadlockCount INT;
SELECT @i=0, @DeadlockCount=0;

WHILE @i<5000 BEGIN ;
  BEGIN TRY 
    EXEC dbo.UpdateCounter1 @Key1=123456;
  END TRY
  BEGIN CATCH
    SET @DeadlockCount = @DeadlockCount + 1;
    ROLLBACK;
  END CATCH ;
  SET @i = @i + 1;
END;
SELECT 'Deadlocks caught: ', @DeadlockCount ;

На іншій вкладці запустіть цей сценарій.

DECLARE @i INT, @DeadlockCount INT;
SELECT @i=0, @DeadlockCount=0;

WHILE @i<5000 BEGIN ;
  BEGIN TRY 
    EXEC dbo.UpdateCounter2 @Key2=123456;
  END TRY
  BEGIN CATCH
    SET @DeadlockCount = @DeadlockCount + 1;
    ROLLBACK;
  END CATCH ;
  SET @i = @i + 1;
END;
SELECT 'Deadlocks caught: ', @DeadlockCount ;

Переконайтеся, що ви починаєте обидва протягом декількох секунд.

Використання sp_getapplock для усунення тупикових ситуацій

Змініть обидві процедури, повторіть цикл і переконайтеся, що у вас більше немає тупиків:

ALTER PROCEDURE dbo.UpdateCounter1 @Key1 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
SET @Key1=@Key1-10000;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
COMMIT;
GO

ALTER PROCEDURE dbo.UpdateCounter2 @Key2 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';
SET @Key2=@Key2-10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
SET @Key2=@Key2+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
COMMIT;
GO

Використання таблиці з одним рядком для усунення тупиків

Замість виклику sp_getapplock ми можемо змінити таку таблицю:

CREATE TABLE dbo.DeadlockTestMutex(
ID INT NOT NULL,
CONSTRAINT PK_DeadlockTestMutex PRIMARY KEY(ID),
Toggle INT NOT NULL);
GO

INSERT INTO dbo.DeadlockTestMutex(ID, Toggle)
VALUES(1,0);

Після створення та заповнення цієї таблиці ми можемо замінити наступний рядок

EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';

з цією, в обох процедурах:

UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;

Ви можете повторно зняти стрес-тест і переконатися, що у нас немає тупиків.

Висновок

Як ми бачили, sp_getapplock може використовуватися для серіалізації доступу до інших ресурсів. Як такий він може бути використаний для усунення тупиків.

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

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

Взагалі, я не думаю, що існує хороший спосіб визначити, чи безпечний ваш T-SQL від тупиків, просто переглянувши його або подивившись план виконання. ІМО - єдиний спосіб визначити, чи ваш код схильний до тупиків - це піддавати його високій сумісності.

Успіхів у усуненні тупиків! У нас взагалі немає жодних тупиків у нашій системі, що чудово підходить для балансу роботи та життя.


2
+1 як sp_getapplock - корисний інструмент, який недостатньо відомий. З огляду на «грізний хаос, який може зайняти час, щоб розібратись, це корисний трюк, щоб серіалізувати процес, який заважає. Але чи повинен це бути перший вибір для подібного випадку, який легко зрозуміти і з яким (можливо, слід) вирішуватись за допомогою стандартних механізмів блокування?
Марк Сторі-Сміт

2
@ MarkStorey-Smith Це мій перший вибір, тому що я досліджував і стрес перевіряв його лише один раз, і можу повторно використовувати його в будь-якій ситуації - серіалізація вже відбулася, тому все, що відбувається після sp_getapplock, не впливає на результат. Зі стандартними механізмами блокування я ніколи не можу бути таким впевненим - додавання індексу або просто отримання іншого плану виконання може спричинити тупики, де раніше їх не було. Запитайте мене, як я знаю.
АК

Я думаю, я пропускаю щось очевидне, але як використання UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;запобігання тупикам?
Дейл К

9

Використання XLOCKпідказки або щодо вашого SELECTпідходу, або щодо наступного, UPDATEмає бути захищене від цього типу тупикової ситуації:

DECLARE @Output TABLE ([NewId] INT);
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN TRANSACTION;

UPDATE
    dbo.tblIDs WITH (XLOCK)
SET 
    LastID = LastID + 1
OUTPUT
    INSERTED.[LastId] INTO @Output
WHERE
    IDName = @IDName;

IF(@@ROWCOUNT = 1)
BEGIN
    SELECT @NewId = [NewId] FROM @Output;
END
ELSE
BEGIN
    SET @NewId = 1;

    INSERT dbo.tblIDs
        (IDName, LastID)
    VALUES
        (@IDName, @NewId);
END

SELECT [NewId] = @NewId ;

COMMIT TRANSACTION;

Повернемось з парою інших варіантів (якщо не бити до нього!).


Хоча XLOCKви не зможете оновлювати існуючий лічильник за допомогою декількох з'єднань, чи не потрібно вам, TABLOCKXщоб унеможливити декілька з'єднань додавати один і той же новий лічильник?
Дейл К

1
@DaleBurrell Ні, у вас буде ПК або унікальне обмеження для IDName.
Марк Сторі-Сміт

7

Майк Дефер показав мені елегантний спосіб досягти цього дуже легким способом:

ALTER PROCEDURE [dbo].[GetNextID](
    @IDName nvarchar(255)
)
AS
BEGIN
    /*
        Description:    Increments and returns the LastID value from tblIDs for a given IDName
        Author:         Max Vernon / Mike Defehr
        Date:           2012-07-19

    */

    DECLARE @Retry int;
    DECLARE @EN int, @ES int, @ET int;
    SET @Retry = 5;
    DECLARE @NewID int;
    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    SET NOCOUNT ON;
    WHILE @Retry > 0
    BEGIN
        BEGIN TRY
            UPDATE dbo.tblIDs 
            SET @NewID = LastID = LastID + 1 
            WHERE IDName = @IDName;

            IF @NewID IS NULL
            BEGIN
                SET @NewID = 1;
                INSERT INTO tblIDs (IDName, LastID) VALUES (@IDName, @NewID);
            END
            SET @Retry = -2; /* no need to retry since the operation completed */
        END TRY
        BEGIN CATCH
            IF (ERROR_NUMBER() = 1205) /* DEADLOCK */
                SET @Retry = @Retry - 1;
            ELSE
                BEGIN
                SET @Retry = -1;
                SET @EN = ERROR_NUMBER();
                SET @ES = ERROR_SEVERITY();
                SET @ET = ERROR_STATE()
                RAISERROR (@EN,@ES,@ET);
                END
        END CATCH
    END
    IF @Retry = 0 /* must have deadlock'd 5 times. */
    BEGIN
        SET @EN = 1205;
        SET @ES = 13;
        SET @ET = 1
        RAISERROR (@EN,@ES,@ET);
    END
    ELSE
        SELECT @NewID AS NewID;
END
GO

(Для повноти, ось таблиця, пов’язана із збереженим процесом)

CREATE TABLE [dbo].[tblIDs]
(
    IDName nvarchar(255) NOT NULL,
    LastID int NULL,
    CONSTRAINT [PK_tblIDs] PRIMARY KEY CLUSTERED 
    (
        [IDName] ASC
    ) WITH 
    (
        PAD_INDEX = OFF
        , STATISTICS_NORECOMPUTE = OFF
        , IGNORE_DUP_KEY = OFF
        , ALLOW_ROW_LOCKS = ON
        , ALLOW_PAGE_LOCKS = ON
        , FILLFACTOR = 100
    ) 
);
GO

Це план виконання для останньої версії:

введіть тут опис зображення

І це план виконання оригінальної версії (сприйнятливий до тупикової ситуації):

введіть тут опис зображення

Ясна річ, нова версія виграє!

Для порівняння, проміжна версія з (XLOCK)т. Д. Створює такий план:

введіть тут опис зображення

Я б сказав, що це виграш! Дякую за допомогу усім!


2
Справді має працювати, але ви використовуєте SERIALIZABLE там, де це не застосовується. Фантомні ряди тут не можуть існувати, тож навіщо використовувати рівень ізоляції, який існує для їх запобігання? Крім того, якщо хтось зателефонував на вашу процедуру з іншого або з з'єднання, де було розпочато зовнішню транзакцію, всі подальші дії, які вони ініціюють, будуть здійснені в СЕРІАЛІЗАЦІЙНОМ. Це може заплутатися.
Марк Сторі-Сміт

2
SERIALIZABLEне існує для запобігання фантомів. Він існує, щоб забезпечити семантизуючу семантику ізоляції , тобто такий самий стійкий вплив на базу даних, як якщо б задіяні транзакції виконувались серійно в якомусь не визначеному порядку.
Пол Білий каже, що GoFundMonica

6

Щоб не вкрасти грім Марка Сторі-Сміта, але він переймається чимось своїм дописом (що, до речі, отримало найбільше відгуків). Порада, яку я дав Максу, була зосереджена на конструкції "UPDATE set @variable = колонка = стовпець + значення", яку я вважаю дуже цікавою, але я думаю, що вона може бути недокументована (вона повинна бути підтримана, хоча тому, що вона спеціально для TCP орієнтири).

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

ALTER PROC [dbo].[GetNextID]
  @IDName nvarchar(255)
  AS
BEGIN
SET NOCOUNT ON;

DECLARE @Output TABLE ([NewID] INT);

UPDATE dbo.tblIDs SET LastID = LastID + 1
OUTPUT inserted.[LastId] INTO @Output
WHERE IDName = @IDName;

IF(@@ROWCOUNT = 1)
    SELECT [NewID] FROM @Output;
ELSE
    INSERT dbo.tblIDs (IDName, LastID)
    OUTPUT INSERTED.LastID AS [NewID]
    VALUES (@IDName,1);
END

3
Погоджено, що це має бути захищеним від тупикової ситуації, але воно схильне до перегонів на вкладиші, якщо ви упустите транзакцію.
Марк Сторі-Сміт

4

Я виправив аналогічний тупик у системі минулого року, змінивши це:

IF (SELECT COUNT(IDName) FROM tblIDs WHERE IDName = @IDName) = 0 
  INSERT INTO tblIDs (IDName, LastID) VALUES (@IDName, @NewID)
ELSE
  UPDATE tblIDs SET LastID = @NewID WHERE IDName = @IDName;

До цього:

UPDATE tblIDs SET LastID = @NewID WHERE IDName = @IDName;
IF @@ROWCOUNT = 0
BEGIN
  INSERT ...
END

Загалом, вибір COUNTсправедливих для визначення присутності чи відсутності є досить марним. У цьому випадку, оскільки це або 0, або 1, це не так, як це багато роботи, але (а) ця звичка може перетікати в інші випадки, коли це буде значно дорожче (у таких випадках використовувати IF NOT EXISTSзамість IF COUNT() = 0) та (b) додаткове сканування зовсім непотрібне. В UPDATEвиконує по суті , той же чек.

Крім того, це виглядає як серйозний запах коду для мене:

SET @NewID = COALESCE((SELECT LastID FROM tblIDs WHERE IDName = @IDName),0)+1;

Який сенс тут? Чому б не просто використовувати стовпець ідентифікації або отримати цю послідовність, використовуючи ROW_NUMBER()час запиту?


Більшість таблиць у нас використовують IDENTITY. Ця таблиця підтримує деякі застарілі коди, написані в MS Access, які були б досить залучені до модернізації. SET @NewID=Лінія просто збільшує значення , збережене в таблиці для даного ID (але ви вже знаєте , що). Чи можете ви розширити, як я можу використовувати ROW_NUMBER()?
Макс Вернон

@MaxVernon не знаючи, що LastIDнасправді означає у вашій моделі. Яке його призначення? Назва не зовсім зрозуміла. Як Access це використовує?
Аарон Бертран

Функція в Access хоче додати рядок до будь-якої заданої таблиці, яка не має ідентичності. Перший дзвінок Access GetNextID('WhatevertheIDFieldIsCalled')отримує наступний ідентифікатор, який потрібно використовувати, а потім вставляє його в новий рядок разом із необхідними даними.
Макс Вернон

Я впроваджу ваші зміни. Чистий випадок "менше - більше"!
Макс Вернон

1
Ваш фіксований глухий кут може з’явитися знову. Ваша друга модель також вразлива: sqlblog.com/blogs/alexander_kuznetsov/archive/2010/01/12/… Для усунення тупикових ситуацій я б використав sp_getapplock. Можлива змішана система завантаження з сотнями користувачів не має тупиків.
АК
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.