PAYATHON 2026

如何使用 OLEDB connection 锁定 Access 记录或表?

支付阿杰

结论

在不修改现有 MDB 结构的情况下,可以安全地分配编号,但没必要逐字复刻 Recordset.Edit 的行为。

建议按以下方式处理:

  1. 在同一个 OleDbConnection 上启动 OleDbTransaction。
  2. 先更新 DBstaus 中的唯一记录,例如直接将两个编号各加一。
  3. 在同一事务中读取更新后的编号。
  4. 使用这些编号写入 invoice、payment 和其他 order 数据。
  5. 全部操作成功后提交事务。任何一步失败都回滚;如果遇到锁冲突,则进行有限次数的重试。

事务并不只是用来隔离同一连接中的线程。事务和锁实际由 Access 使用的 Jet/ACE 数据库引擎执行,因此其他进程、其他连接和旧版 VB6 程序同样会受到这些锁的约束。

为什么不能直接使用 Recordset.Edit

OleDbConnection 和 OleDbCommand 没有与 DAO/ADO Recordset.Edit 完全对应的 API。OleDbDataReader 只能向前读取,而且是只读的,无法通过进入某种”编辑模式”长期持有可编辑记录。

实际需要保证的是以下操作不会被并发请求打断:

取得编号 → 增加编号 → 保存订单 → 提交新编号

事务可以保证这一点,但必须先执行写操作。单独执行 SELECT,通常不会获得足以阻止其他用户更新该记录的排他锁。

推荐的执行顺序

不要采用以下顺序:

SELECT 当前编号
计算新编号
UPDATE 编号

因为两个用户可能同时读到同一个旧编号。

应改为:

BeginTransaction
UPDATE DBstaus SET 编号 = 编号 + 1
SELECT 更新后的编号
写入 invoice、payment 和其他订单数据
Commit

第一个 UPDATE 会要求数据库引擎取得写锁。如果另一个用户同时执行相同操作,数据库引擎会让它等待或返回锁冲突,不会让两次编号分配相互覆盖而不报错。

VB.NET 示例

下面的示例假设每次保存只分配一个 invoice number 和一个 payment number,并且两个字段都不允许为 NULL。

Imports System.Data
Imports System.Data.OleDb

Public Sub SaveOrder(databasePath As String)
    Dim connectionString =
        $"Provider=Microsoft.Jet.OLEDB.4.0;Data Source={databasePath};"

    Using connection As New OleDbConnection(connectionString)
        connection.Open()

        Using transaction As OleDbTransaction = connection.BeginTransaction()
            Try
                Dim invoiceNumber As Double
                Dim paymentNumber As Long

                ' 先更新,促使 Jet 对包含该记录的数据页或记录取得写锁。
                Using updateStatus As New OleDbCommand(
                    "UPDATE [DBstaus] " &
                    "SET [Invoice number] = [Invoice number] + 1, " &
                    "    [Payment number] = [Payment number] + 1",
                    connection,
                    transaction)

                    Dim affectedRows = updateStatus.ExecuteNonQuery()

                    If affectedRows <> 1 Then
                        Throw New InvalidOperationException(
                            $"更新 DBstaus 时影响了 {affectedRows} 条记录,预期为 1 条。")
                    End If
                End Using

                ' 必须使用同一个 connection 和 transaction。
                Using readStatus As New OleDbCommand(
                    "SELECT [Invoice number], [Payment number] FROM [DBstaus]",
                    connection,
                    transaction)

                    Using reader = readStatus.ExecuteReader()
                        If reader Is Nothing OrElse Not reader.Read() Then
                            Throw New InvalidOperationException(
                                "DBstaus 中没有找到状态记录。")
                        End If

                        invoiceNumber = Convert.ToDouble(
                            reader("Invoice number"))

                        paymentNumber = Convert.ToInt64(
                            reader("Payment number"))

                        If reader.Read() Then
                            Throw New InvalidOperationException(
                                "DBstaus 中存在多条记录,无法确定应使用哪一条。")
                        End If
                    End Using
                End Using

                ' 下面所有订单写入也必须参加同一个事务。
                Using insertInvoice As New OleDbCommand(
                    "INSERT INTO [Invoices] " &
                    "([Invoice number], [Order ID]) VALUES (?, ?)",
                    connection,
                    transaction)

                    insertInvoice.Parameters.Add(
                        "@InvoiceNumber", OleDbType.Double).Value = invoiceNumber

                    insertInvoice.Parameters.Add(
                        "@OrderId", OleDbType.Integer).Value = GetOrderId()

                    insertInvoice.ExecuteNonQuery()
                End Using

                Using insertPayment As New OleDbCommand(
                    "INSERT INTO [Payments] " &
                    "([Payment number], [Invoice number]) VALUES (?, ?)",
                    connection,
                    transaction)

                    insertPayment.Parameters.Add(
                        "@PaymentNumber", OleDbType.Integer).Value = paymentNumber

                    insertPayment.Parameters.Add(
                        "@InvoiceNumber", OleDbType.Double).Value = invoiceNumber

                    insertPayment.ExecuteNonQuery()
                End Using

                ' 其他订单保存操作放在这里,并使用同一个 transaction。

                transaction.Commit()

            Catch
                Try
                    transaction.Rollback()
                Catch
                    ' 原始异常更有诊断价值,不在这里覆盖它。
                End Try

                Throw
            End Try
        End Using
    End Using
End Sub

其中的 GetOrderId() 只是业务代码占位符,需要替换成实际的订单 ID。

还需注意,OleDb 参数按照出现顺序绑定。即使为参数指定了名称,实际生效的仍是它们加入 Parameters 集合的顺序。

多个编号或编号区间

如果一次保存需要创建多个 invoice 或 payment,应一次增加相应的数量,不要在循环中反复读取和更新:

Using command As New OleDbCommand(
    "UPDATE [DBstaus] " &
    "SET [Invoice number] = [Invoice number] + ?, " &
    "    [Payment number] = [Payment number] + ?",
    connection,
    transaction)

    command.Parameters.Add(
        "@InvoiceCount", OleDbType.Integer).Value = invoiceCount

    command.Parameters.Add(
        "@PaymentCount", OleDbType.Integer).Value = paymentCount

    If command.ExecuteNonQuery() <> 1 Then
        Throw New InvalidOperationException(
            "无法更新唯一的 DBstaus 记录。")
    End If
End Using

随后读取到的是编号区间的末尾值。区间起始值可以这样计算:

Dim firstInvoiceNumber = lastInvoiceNumber - invoiceCount + 1
Dim firstPaymentNumber = lastPaymentNumber - paymentCount + 1

锁冲突重试

Access 是文件型数据库。发生并发更新时,它可能直接返回锁冲突,而不是一直等待。遇到这种情况,应回滚整个事务,短暂等待后从头重试。不要只重试最后一条 SQL,因为事务此前的状态可能已经失效。

Imports System.Data.OleDb
Imports System.Threading

Public Sub SaveOrderWithRetry(databasePath As String)
    Const maxAttempts As Integer = 5

    For attempt = 1 To maxAttempts
        Try
            SaveOrder(databasePath)
            Return

        Catch ex As OleDbException When IsLockConflict(ex)
            If attempt = maxAttempts Then
                Throw New InvalidOperationException(
                    "数据库当前正在被其他用户更新,请稍后重试。",
                    ex)
            End If

            Thread.Sleep(100 * attempt)
        End Try
    Next
End Sub

Private Function IsLockConflict(ex As OleDbException) As Boolean
    For Each item As OleDbError In ex.Errors
        Select Case item.NativeError
            Case 3188, 3197, 3218, 3260
                Return True
        End Select
    Next

    Return False
End Function

这些编号是 Jet/Access 环境中常见的锁相关错误,但具体返回值会受到 Provider、SQL 操作和数据库状态影响。部署前应记录实际的 OleDbException.Errors,确认当前环境会返回哪些 NativeError。不要把所有数据库异常都当作锁冲突重试。

是否应让整个订单保存都处于同一事务

如果订单保存失败后,已分配的编号也必须恢复,那么编号更新和所有订单写入都要放在同一个事务中:

更新编号
读取编号
保存订单全部数据
提交事务

这样无论哪一步失败,回滚都会同时撤销编号更新和订单数据。

相应的代价是,锁会一直保留到订单保存结束。因此,事务中不应包含用户确认、网络请求、文件处理、报表生成或其他耗时操作。应在事务外准备并验证数据,然后打开连接,尽快完成数据库写入。

如果允许编号出现空缺,也可以用单独的短事务分配编号并立即提交,再保存订单。这样能明显缩短锁定时间,但如果订单保存失败,已经分配的编号不会被回收。invoice number 能否出现空缺,需要根据财务和审计要求决定。

关于记录锁和表锁

Access/Jet 的锁粒度会受到数据库格式、Provider、数据库设置和具体操作影响。即使启用了记录级锁,数据库引擎在某些情况下仍可能锁定包含该记录的数据页,因此不能假定它一定只锁一条记录。

这里通常不需要锁住整个 invoice.mdb,也不需要阻止所有读取。只要确保并发编号分配无法同时成功,就能保证编号唯一。更新 DBstaus 并保持事务,已经可以达到这个目的。

现有表结构的风险点

当前结构可以继续使用,但需要留意以下问题:

  • DBstaus 没有明确展示主键。即使现在只有一条记录,程序仍应检查 UPDATE 影响的行数是否恰好为 1。
  • [Invoice number] 使用 Double 保存整数编号并不合适。数值较大时可能受到浮点精度影响。为了短期兼容旧系统可以继续保留,但长期更适合改成 Long、Decimal 或文本类型,具体取决于编号规则。
  • invoice 和 payment 表最好为相应编号建立唯一索引。事务可以避免正常的并发冲突,唯一索引则提供最后一层数据完整性保护。
  • 如果事务涉及 Excel linked table、不同 MDB 中的 linked table,或不支持事务的数据源,就不能假定所有写入都能一起回滚。需要根据实际使用的 Provider 和链接类型进行验证。
  • 参与事务的所有命令都必须明确使用同一个 OleDbConnection 和同一个 OleDbTransaction。中途重新打开连接会脱离当前事务。

解决当前的并发编号问题并不要求立即修改数据库结构。采用”先原子更新、再读取、在同一事务中保存、发生锁冲突时整笔重试”的方式,就可以在保留现有 MDB 的情况下替代原来的 Recordset.Edit 方案。

备注:内容仅供参考。