Управление транзакциями с помощью mssql-python

Драйвер mssql-python поддерживает полный контроль над транзакциями, включая подтверждение, откат, настройку автокоммита и уровни изоляции транзакций.

Основы транзакций

Транзакция объединяет последовательность операций с базой данных в одну единицу работы. Сделки следуют свойствам ACID:

  • Атомичность: Все операции либо успешны, либо все проваливаются.
  • Согласованность: база данных остаётся в действительном состоянии.
  • Изоляция: Одновременные транзакции не мешают друг другу.
  • Долговечность: Зафиксированные изменения сохраняются после сбоев системы.

Режим автоматической фиксации транзакций

Параметр autocommit определяет, фиксируются ли изменения автоматически.

Автокоммит отключён (по умолчанию)

По умолчанию autocommit=False. Вы должны явно внести изменения.

import mssql_python

conn = mssql_python.connect(connection_string)
print(conn.autocommit)  # False

cursor = conn.cursor()
cursor.execute("CREATE TABLE #TxnBasic (Name NVARCHAR(50))")
cursor.execute("INSERT INTO #TxnBasic (Name) VALUES ('Widget')")
cursor.execute("INSERT INTO #TxnBasic (Name) VALUES ('Gadget')")

# Changes are staged but not visible to other connections
conn.commit()  # Now changes are permanent

conn.close()

Если вы не фиксируете изменения, драйвер отбрасывает их при закрытии соединения.

Включено автокоммит

Когда вы устанавливаете autocommit=True, драйвер сразу фиксирует каждое утверждение:

conn = mssql_python.connect(connection_string, autocommit=True)
# OR
conn.setautocommit(True)

cursor = conn.cursor()
cursor.execute("CREATE TABLE #AutoDemo (Name NVARCHAR(50))")
cursor.execute("INSERT INTO #AutoDemo (Name) VALUES ('Widget')")
# Immediately committed - no explicit commit needed

Предостережение

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

Фиксация и откат

Зафиксировать

Призыв commit() сделать ожидающие изменения постоянными:

cursor.execute("CREATE TABLE #CommitDemo (Name NVARCHAR(50), Price DECIMAL(10,2), CategoryID INT)")
cursor.execute("INSERT INTO #CommitDemo VALUES ('A',10,1),('B',20,1)")
cursor.execute("UPDATE #CommitDemo SET Price = Price * 1.1 WHERE CategoryID = 1")

conn.commit()  # The update is now permanent

Откат

Чтобы отменить ожидающие изменения, позвоните rollback():

try:
    cursor.execute("CREATE TABLE #RollDemo (Name NVARCHAR(50), Price DECIMAL(10,2), CategoryID INT)")
    cursor.execute("INSERT INTO #RollDemo VALUES ('A',10,1),('B',20,1)")
    cursor.execute("UPDATE #RollDemo SET Price = Price * 1.1 WHERE CategoryID = 1")
    
    # Verify the update
    cursor.execute("SELECT AVG(Price) FROM #RollDemo WHERE CategoryID = 1")
    avg_price = cursor.fetchval()
    
    if avg_price > 100:
        conn.rollback()  # Price too high, undo both updates
        print("Rolled back: average price would exceed limit")
    else:
        conn.commit()
except Exception as e:
    conn.rollback()  # Undo on error
    raise

Фиксация на уровне курсора и откат

Для удобства можно вызывать commit() и rollback() для курсоров:

cursor = conn.cursor()
cursor.execute("CREATE TABLE #CursorDemo (Name NVARCHAR(50))")
cursor.execute("INSERT INTO #CursorDemo (Name) VALUES ('Widget')")
cursor.commit()  # Delegates to connection

cursor.execute("DELETE FROM #CursorDemo WHERE Name = 'Widget'")
cursor.rollback()  # Delegates to connection

Замечание

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

Менеджеры контекста

Менеджер контекста соединения фиксирует транзакцию при нормальном завершении и откатывает её, если возникает исключение. Соединение всегда закрывается при выходе. Когда вы устанавливаете autocommit=True, вызовы commit и rollback не имеют никакого эффекта.

with mssql_python.connect(connection_string) as conn:
    cursor = conn.cursor()
    cursor.execute("CREATE TABLE #CtxDemo (Name NVARCHAR(50))")
    cursor.execute("INSERT INTO #CtxDemo (Name) VALUES ('Widget')")
    cursor.execute("INSERT INTO #CtxDemo (Name) VALUES ('Gadget')")
# Transaction is committed and connection is closed on exit

Если возникает исключение, транзакция откатывается:

try:
    with mssql_python.connect(connection_string) as conn:
        cursor = conn.cursor()
        cursor.execute("CREATE TABLE #TxnDemo (Name NVARCHAR(50))")
        cursor.execute("INSERT INTO #TxnDemo (Name) VALUES ('Widget')")
        raise ValueError("Something went wrong")
except ValueError:
    pass
# Transaction is rolled back and connection is closed on exit

Уровни изоляции транзакций

Уровни изоляции контролируют, как транзакции взаимодействуют с параллельными транзакциями. Установить уровень изоляции с помощью set_attr():

import mssql_python

conn = mssql_python.connect(connection_string)

# Set isolation level
conn.set_attr(
    mssql_python.SQL_ATTR_TXN_ISOLATION,
    mssql_python.SQL_TXN_SERIALIZABLE
)

Доступные уровни изоляции

Постоянный Описание
SQL_TXN_READ_UNCOMMITTED Может читать неподтверждённые изменения других транзакций (допускаются грязные чтения)
SQL_TXN_READ_COMMITTED Читает только фиксированные данные (по умолчанию в SQL Server)
SQL_TXN_REPEATABLE_READ Гарантирует единообразные считывания внутри транзакции
SQL_TXN_SERIALIZABLE Максимальная изоляция; Транзакции, по-видимому, выполняются последовательно

Выберите уровень изоляции

Сценарий использования Рекомендуемый уровень
Общие нагрузки OLTP READ_COMMITTED (по умолчанию)
Отчёты, которым нужны последовательные снимки REPEATABLE_READ или снимок
Финансовые расчёты, требующие точности SERIALIZABLE
Нагрузки с преобладанием операций чтения, допускающие устаревание данных READ_UNCOMMITTED

Изоляция моментальных снимков

Для выделения снимков используйте Transact-SQL (T-SQL). Изоляция снимков использует версионирование строк в tempdb, что может увеличить требования к хранилищу при больших нагрузках на запись.

# Enable snapshot isolation on the database (one-time setup, requires autocommit)
conn.commit()
conn.autocommit = True
cursor.execute("ALTER DATABASE AdventureWorks2022 SET ALLOW_SNAPSHOT_ISOLATION ON")

# Set isolation level while still in autocommit, then start the transaction
cursor.execute("SET TRANSACTION ISOLATION LEVEL SNAPSHOT")
conn.autocommit = False

cursor.execute("SELECT TOP 5 Name, ListPrice FROM Production.Product")
rows = cursor.fetchall()
for row in rows:
    print(row.Name, row.ListPrice)
conn.commit()

Вложенные транзакции и точки сохранения

SQL Server поддерживает точки сохранения для частичного отката в рамках транзакции.

cursor = conn.cursor()

cursor.execute("BEGIN TRANSACTION")
cursor.execute("CREATE TABLE #SaveDemo (Name NVARCHAR(50))")
cursor.execute("INSERT INTO #SaveDemo (Name) VALUES ('Widget')")

cursor.execute("SAVE TRANSACTION SaveDemoPoint")
cursor.execute("INSERT INTO #SaveDemo (Name) VALUES ('Gadget')")

# Roll back to savepoint, keeping first insert
cursor.execute("ROLLBACK TRANSACTION SaveDemoPoint")

cursor.execute("COMMIT TRANSACTION")

Управление взаимоблокировками

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

import time

def execute_with_retry(conn, cursor, sql, params=None, max_retries=3):
    """Execute SQL with deadlock retry logic."""
    for attempt in range(max_retries):
        try:
            cursor.execute(sql, params)
            return
        except mssql_python.OperationalError as e:
            if "1205" in str(e):  # Deadlock error number
                if attempt < max_retries - 1:
                    conn.rollback()  # Clear the failed transaction
                    time.sleep(0.1 * (2 ** attempt))  # Exponential backoff
                    continue
            raise
    raise Exception(f"Failed after {max_retries} attempts")

Лучшие практики

  • Держите транзакции короткими , чтобы минимизировать длительность блокировки и возможность тупиков.

  • Используйте autocommit=False для транзакций, включающих несколько инструкций, которые должны быть атомарными.

  • Всегда обрабатывайте исключения с откатом:

    conn = None
    try:
         conn = mssql_python.connect(connection_string)
         cursor = conn.cursor()
         cursor.execute("CREATE TABLE #RollbackPattern (ID INT, Name NVARCHAR(50))")
         cursor.execute("INSERT INTO #RollbackPattern (ID, Name) VALUES (1, 'Widget')")
         cursor.execute("UPDATE #RollbackPattern SET Name = 'Updated Widget' WHERE ID = 1")
         conn.commit()
    except Exception:
         if conn is not None:
             conn.rollback()
         raise
    finally:
         if conn is not None:
             conn.close()
    
  • Используйте контекстные менеджеры для автоматического управления транзакциями. Контекстный менеджер фиксирует изменения при штатном завершении и выполняет откат при возникновении исключения.

    with mssql_python.connect(connection_string) as conn:
         cursor = conn.cursor()
         cursor.execute("CREATE TABLE #ContextManagerDemo (ID INT, Name NVARCHAR(50))")
         cursor.execute("INSERT INTO #ContextManagerDemo (ID, Name) VALUES (1, 'Widget')")
         cursor.execute("UPDATE #ContextManagerDemo SET Name = 'Committed Widget' WHERE ID = 1")
    # Committed automatically on exit
    
  • Выбирайте подходящие уровни изоляции , исходя из ваших требований к стабильности и производительности.

  • Используйте подсказки блокировки для паттернов чтения-модификации и записи , чтобы предотвратить потерю обновлений. Когда вы читаете значение, которое будет обновляться в рамках той же транзакции, используйте подсказки, например WITH (UPDLOCK, ROWLOCK) на SELECT, чтобы получить замки на раннем этапе и установить единый порядок замков, что снижает риск тупиков.

    # Good: Acquire lock during read to prevent lost update pattern
    cursor.execute("""
         SELECT Balance FROM Accounts 
         WITH (UPDLOCK, ROWLOCK) 
         WHERE ID = %(id)s
    """, {"id": account_id})
    balance = cursor.fetchval()
    
    if balance >= amount:
         cursor.execute("""
             UPDATE Accounts SET Balance = Balance - %(amount)s 
             WHERE ID = %(id)s
         """, {"amount": amount, "id": account_id})
    
  • Реализуйте повторную логику при временных сбоях, таких как тупиковые блокировки.

Пример: Перевод средств (атомная операция)

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

def transfer_funds(conn, from_account, to_account, amount):
    """Transfer funds atomically between accounts."""
    cursor = conn.cursor()
    
    try:
        # Read balance with lock hint to prevent lost updates
        cursor.execute(
            "SELECT Balance FROM Accounts WITH (UPDLOCK, ROWLOCK) WHERE AccountID = %(account_id)s",
            {"account_id": from_account}
        )
        balance = cursor.fetchval()
        
        if balance is None:
            raise ValueError(f"Source account {from_account} not found")
        
        if balance < amount:
            raise ValueError("Insufficient funds")
        
        # Debit source account
        cursor.execute(
            "UPDATE Accounts SET Balance = Balance - %(amount)s WHERE AccountID = %(account_id)s",
            {"amount": amount, "account_id": from_account}
        )
        
        # Credit destination account
        cursor.execute(
            "UPDATE Accounts SET Balance = Balance + %(amount)s WHERE AccountID = %(account_id)s",
            {"amount": amount, "account_id": to_account}
        )
        
        if cursor.rowcount != 1:
            raise ValueError(f"Destination account {to_account} not found")
        
        conn.commit()
        print(f"Transferred ${amount} from {from_account} to {to_account}")
        
    except Exception:
        conn.rollback()
        raise

Блокировочные подсказки WITH (UPDLOCK, ROWLOCK) в операторе SELECT гарантируют, что блокировка устанавливается заранее. Это предотвращает одновременное чтение того же баланса другой транзакции и создание сценария потерянного обновления, при котором обе транзакции считывают старый баланс, выполняют отдельные обновления, и сохраняется только последнее обновление.

Область видимости временной таблицы

Временные таблицы (#tablename) ограничены сессией, но их создание является частью текущей транзакции. Если вы создаёте временную таблицу и транзакция откатывается, временная таблица удаляется:

conn = mssql_python.connect(connection_string)  # autocommit=False
cursor = conn.cursor()

cursor.execute("CREATE TABLE #Staging (ID INT, Name NVARCHAR(50))")
cursor.execute("INSERT INTO #Staging VALUES (1, 'test')")

# Rollback removes the temp table entirely
conn.rollback()

# This fails: Invalid object name '#Staging'
try:
    cursor.execute("SELECT * FROM #Staging")
except mssql_python.ProgrammingError:
    print("Temp table was dropped by rollback")

Чтобы временная таблица была независима от вашей транзакции с данными, сделайте коммит после её создания:

cursor.execute("CREATE TABLE #Staging (ID INT, Name NVARCHAR(50))")
conn.commit()  # Temp table persists regardless of later rollbacks

# Now data operations can roll back without losing the table
cursor.execute("INSERT INTO #Staging VALUES (1, 'test')")
conn.rollback()  # Data is gone, but #Staging still exists

DDL-операторы, требующие автоподтверждения

Некоторые DDL-операторы, такие как CREATE DATABASE, ALTER DATABASE, и DROP DATABASE, не могут выполняться внутри транзакции. Установите autocommit=True перед их выполнением:

conn.autocommit = True
cursor.execute("CREATE DATABASE TestDB")
conn.autocommit = False  # Return to transactional mode

Если вы запускаете CREATE DATABASE с autocommit=False, вы получаете ошибку: CREATE DATABASE statement not allowed within multi-statement transaction.