MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

SQLite锁机制与死锁预防策略

2021-10-095.4k 阅读

SQLite 锁机制概述

SQLite 作为一款轻量级的嵌入式数据库,在很多应用场景中被广泛使用。它的锁机制是保证数据一致性和并发访问正确性的关键组成部分。

SQLite 使用一种细粒度的锁机制,它基于页(page)来进行加锁。一个 SQLite 数据库文件被划分成多个页,这些页是数据存储和操作的基本单位。在并发访问时,不同的事务可能会对不同的页进行读写操作,锁机制就是用来协调这些操作,防止数据冲突。

锁的粒度

SQLite 的锁粒度基于页,这意味着一个事务可以独立地锁定和操作单个页,而不会影响其他页。相比一些数据库以表为锁粒度,SQLite 的页级锁能提供更高的并发性能。例如,在一个包含大量数据的表中,如果两个事务分别操作不同的数据页,它们可以同时进行,而不需要相互等待对方释放锁。

锁的类型

SQLite 定义了多种类型的锁,主要包括以下几种:

  1. 共享锁(SHARED):共享锁用于读操作。当一个事务需要读取一个页时,它会请求该页的共享锁。多个事务可以同时持有同一个页的共享锁,因为读操作不会修改数据,所以不会产生冲突。例如,多个查询操作可以同时进行,它们都持有共享锁。
  2. 排它锁(EXCLUSIVE):排它锁用于写操作。当一个事务需要修改一个页时,它必须先获取该页的排它锁。排它锁不允许其他事务同时持有该页的任何锁,无论是共享锁还是排它锁。这是为了确保在写操作期间,数据不会被其他事务修改,保证数据的一致性。
  3. 保留锁(RESERVED):保留锁介于共享锁和排它锁之间。当一个事务开始准备写操作,但还没有实际进行写时,会获取保留锁。持有保留锁的事务表示它将来可能会对该页进行写操作,但目前仍允许其他事务进行读操作(持有共享锁)。

SQLite 锁机制的工作原理

了解 SQLite 锁机制的工作原理对于深入理解并发访问和死锁预防非常重要。

事务开始

当一个事务开始时,SQLite 会根据事务的操作类型(读或写)来决定获取何种类型的锁。如果是读操作,事务会尝试获取共享锁;如果是写操作,事务首先会获取保留锁。

锁的升级

随着事务的执行,如果一个持有保留锁的事务需要进行实际的写操作,它会将保留锁升级为排它锁。在锁升级过程中,SQLite 会等待所有其他事务释放该页的共享锁,因为排它锁不允许与其他锁共存。

锁的释放

当一个事务完成(提交或回滚)时,它会释放所有持有的锁。这样,其他事务就可以获取这些锁,继续进行操作。例如,一个读事务在读取完数据并提交后,会释放共享锁,允许其他事务获取锁进行读或写操作。

死锁的概念与 SQLite 中的死锁场景

死锁是指两个或多个事务相互等待对方释放锁,从而导致所有事务都无法继续执行的情况。在 SQLite 中,虽然页级锁机制减少了死锁的发生概率,但仍然存在死锁的可能。

死锁的定义

死锁是一种系统状态,在这种状态下,一组事务中的每个事务都在等待其他事务释放锁,而这些锁又被其他事务持有,形成了一个循环等待的局面。例如,事务 A 持有页 P1 的锁并等待页 P2 的锁,而事务 B 持有页 P2 的锁并等待页 P1 的锁,这样就形成了死锁。

SQLite 中的死锁场景

  1. 交叉锁场景:假设事务 T1 先获取了页 P1 的共享锁,然后尝试获取页 P2 的排它锁;同时,事务 T2 先获取了页 P2 的共享锁,然后尝试获取页 P1 的排它锁。由于排它锁需要等待所有共享锁释放,T1 等待 T2 释放页 P2 的共享锁,T2 等待 T1 释放页 P1 的共享锁,从而导致死锁。
  2. 嵌套事务场景:在一些复杂的事务嵌套结构中,也可能出现死锁。例如,外层事务 T1 获取了页 P1 的锁,内层事务 T2 在执行过程中需要获取页 P2 的锁,而 T2 又需要等待 T1 释放页 P1 的锁才能继续,同时 T1 又依赖 T2 的结果来释放页 P1 的锁,这就形成了死锁。

死锁预防策略

为了避免死锁的发生,SQLite 提供了一些机制和开发者可以采用的策略。

合理的事务设计

  1. 减少事务的持有时间:尽量缩短事务的执行时间,减少锁的持有时间。例如,将一个大的事务拆分成多个小的事务,每个小事务只完成必要的操作,然后尽快提交。这样可以减少其他事务等待锁的时间,降低死锁的可能性。
  2. 按照相同顺序访问资源:如果多个事务需要访问多个页,确保它们按照相同的顺序获取锁。例如,所有事务都先获取页 P1 的锁,再获取页 P2 的锁,这样可以避免交叉锁导致的死锁。

使用 SQLite 的 PRAGMA 指令

  1. PRAGMA locking_mode:这个指令可以设置 SQLite 的锁模式。默认情况下,SQLite 使用 NORMAL 模式,在这种模式下,事务在开始时获取保留锁,然后根据需要升级为排它锁。可以将锁模式设置为 EXCLUSIVE,在这种模式下,事务一开始就获取排它锁,这样可以避免锁升级过程中可能出现的死锁,但会降低并发性能。
import sqlite3

# 连接到数据库
conn = sqlite3.connect('test.db')
# 设置锁模式为 EXCLUSIVE
conn.execute('PRAGMA locking_mode = EXCLUSIVE')
cursor = conn.cursor()
# 执行 SQL 操作
cursor.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)')
conn.commit()
conn.close()
  1. PRAGMA busy_timeout:这个指令设置 SQLite 在获取锁时等待的最长时间。如果在指定时间内无法获取锁,SQLite 会返回一个错误。通过设置合理的 busy_timeout,可以避免事务无限期等待锁,从而减少死锁的发生。
import sqlite3

conn = sqlite3.connect('test.db')
# 设置 busy_timeout 为 5000 毫秒(5 秒)
conn.execute('PRAGMA busy_timeout = 5000')
cursor = conn.cursor()
try:
    cursor.execute('INSERT INTO users (name) VALUES ("John")')
    conn.commit()
except sqlite3.OperationalError as e:
    print(f"操作失败: {e}")
conn.close()

死锁检测与恢复

虽然 SQLite 本身没有内置的死锁检测机制,但开发者可以通过应用层的逻辑来实现死锁检测和恢复。

  1. 重试机制:当事务因获取锁失败而抛出异常时,可以在应用层进行重试。在重试之前,可以等待一段时间,避免频繁重试导致性能问题。
import sqlite3
import time

conn = sqlite3.connect('test.db')
retry_count = 0
while True:
    try:
        cursor = conn.cursor()
        cursor.execute('INSERT INTO users (name) VALUES ("Jane")')
        conn.commit()
        break
    except sqlite3.OperationalError as e:
        if "locked" in str(e) and retry_count < 3:
            retry_count += 1
            time.sleep(1)
        else:
            print(f"操作失败: {e}")
            break
conn.close()
  1. 日志记录与分析:记录事务执行过程中的锁获取和释放信息,通过分析日志来检测是否存在死锁。如果发现死锁,可以手动介入,例如终止某个事务,以打破死锁。

代码示例深入分析

通过具体的代码示例,能更直观地理解 SQLite 锁机制和死锁预防策略的实际应用。

并发读示例

import sqlite3
import threading

def read_data():
    conn = sqlite3.connect('test.db')
    cursor = conn.cursor()
    cursor.execute('SELECT * FROM users')
    rows = cursor.fetchall()
    for row in rows:
        print(row)
    conn.close()

# 创建多个线程进行并发读
threads = []
for _ in range(5):
    t = threading.Thread(target=read_data)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

在这个示例中,多个线程同时执行读操作。由于读操作获取的是共享锁,多个线程可以同时持有共享锁,从而实现并发读,不会出现锁冲突。

并发写示例

import sqlite3
import threading

def write_data():
    conn = sqlite3.connect('test.db')
    cursor = conn.cursor()
    cursor.execute('INSERT INTO users (name) VALUES ("Alice")')
    conn.commit()
    conn.close()

# 创建多个线程进行并发写
threads = []
for _ in range(5):
    t = threading.Thread(target=write_data)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

在并发写示例中,每个线程执行写操作时会获取排它锁。由于排它锁不允许其他事务同时持有锁,所以这些线程会依次获取排它锁进行写操作,不会出现死锁,但会影响并发性能。

死锁模拟示例

import sqlite3
import threading

def transaction1():
    conn1 = sqlite3.connect('test.db')
    cursor1 = conn1.cursor()
    cursor1.execute('BEGIN')
    cursor1.execute('SELECT * FROM page1')
    time.sleep(1)
    cursor1.execute('SELECT * FROM page2')
    conn1.commit()
    conn1.close()

def transaction2():
    conn2 = sqlite3.connect('test.db')
    cursor2 = conn2.cursor()
    cursor2.execute('BEGIN')
    cursor2.execute('SELECT * FROM page2')
    time.sleep(1)
    cursor2.execute('SELECT * FROM page1')
    conn2.commit()
    conn2.close()

t1 = threading.Thread(target=transaction1)
t2 = threading.Thread(target=transaction2)

t1.start()
t2.start()

t1.join()
t2.join()

在这个死锁模拟示例中,transaction1 先获取 page1 的锁,然后尝试获取 page2 的锁,而 transaction2 先获取 page2 的锁,然后尝试获取 page1 的锁,由于相互等待,会导致死锁。

死锁预防示例

import sqlite3
import threading

def transaction3():
    conn3 = sqlite3.connect('test.db')
    conn3.execute('PRAGMA busy_timeout = 3000')
    cursor3 = conn3.cursor()
    cursor3.execute('BEGIN')
    try:
        cursor3.execute('SELECT * FROM page1')
        cursor3.execute('SELECT * FROM page2')
        conn3.commit()
    except sqlite3.OperationalError as e:
        print(f"操作失败: {e}")
        conn3.rollback()
    conn3.close()

def transaction4():
    conn4 = sqlite3.connect('test.db')
    conn4.execute('PRAGMA busy_timeout = 3000')
    cursor4 = conn4.cursor()
    cursor4.execute('BEGIN')
    try:
        cursor4.execute('SELECT * FROM page1')
        cursor4.execute('SELECT * FROM page2')
        conn4.commit()
    except sqlite3.OperationalError as e:
        print(f"操作失败: {e}")
        conn4.rollback()
    conn4.close()

t3 = threading.Thread(target=transaction3)
t4 = threading.Thread(target=transaction4)

t3.start()
t4.start()

t3.join()
t4.join()

在这个死锁预防示例中,通过设置 PRAGMA busy_timeout,当获取锁超过 3 秒时,事务会抛出异常并回滚,避免了无限期等待导致的死锁。

总结 SQLite 锁机制与死锁预防的要点

  1. 锁机制基础:SQLite 基于页级锁,有共享锁、排它锁和保留锁等类型。理解这些锁的获取、升级和释放过程,是掌握并发访问的关键。
  2. 死锁场景:交叉锁和嵌套事务等场景容易导致死锁,开发者需要清楚认识这些场景,以便采取预防措施。
  3. 预防策略:合理设计事务,使用 SQLite 的 PRAGMA 指令,以及在应用层实现死锁检测与恢复,都是有效的死锁预防方法。通过实际的代码示例,我们可以更好地将这些策略应用到实际项目中,确保 SQLite 数据库在并发环境下的稳定运行。