在多租户 MySQL 数据库中生成(和保存)增量发票编号的最佳方式

Best way to generate (and save) incremental invoice numbers in a multi-tenant MySQL database

我找到了两种不同的方法,首先,获取下一个发票编号,然后将发票保存在多租户数据库中,当然,每个租户都有自己的发票,增量编号不同。

  1. 向发票表添加一条新记录。不管发票号还没有(例如,0,或为空)
  2. 我在插入后获得了创建记录的唯一 ID
  3. 现在我做一个"SELECT table where ID = $lastcreatedID **FOR UPDATE**"
  4. 这里我用"SELECT @A:=MAX(NUMBER)+1 FROM TABLE WHERE......"
  5. 获取最新保存的发票号码
  6. 最后,我用 "UPDATE table SET NUMBER = $mynumber WHERE ID = $lastcreatedID"
  7. 的发票编号更新了之前保存的记录

这工作正常,但我不知道是否真的需要“for update”,或者由于性能等原因,这是否是在多租户数据库中执行此操作的正确方法

  1. INSERT INTO table (NUMBER,TENANT) SELECT COALESCE(MAX(NUMBER),0)+1,$tenant FROM table WHERE....
  2. 就是这样

这两种方法都有效,但我想知道它们在速度、性能、是否可能创建重复项等方面的差异

或者...有更好的方法吗?

我正在使用 MySQL 和 PHP。该应用程序是一个invoice/sales云软件,将被很多客户(租户)使用。

谢谢

这两种方法都有效,但在高流量情况下各有其缺点。

第一种方法对您创建的每张发票运行 3 次查询,给您的服务器带来额外负载。

第二种方法可能导致在生成两张发票的时间差很小的事件中出现重复(这样 SELECT 查询 return 两张发票的最大数量相同)。

这两种方法都可能在高流量条件下导致问题。

问题的两种解决方案如下:

  1. 使用生成的列:Mysql支持生成的列,这些列基本上是使用每一行的其他列值派生的。参考 this

  2. 即时计算发票编号:由于您将主键用作发票的一部分,因此让数据库处理生成唯一的主键,然后使用每张发票的 ID 在您的业务逻辑中即时生成发票编号。

无论您是否将这些值用作数据库 ID,重复使用 ID 实际上肯定会在某些时候引起问题。即使您不重复使用 ID,您也会 运行 遇到两个发票创建请求同时 运行 并获得相同 MAX()+1 结果的情况。

要解决所有这些问题,您需要重新实现一个简单的序列生成器,该生成器在发出值时锁定其存储。例如:

CREATE TABLE client_invoice_serial (
  -- note: also FK this back to the client record
  client_id INTEGER UNSIGNED NOT NULL PRIMARY KEY,
  serial INTEGER UNSIGNED NOT NULL DEFAULT 0
);
$dbh = new PDO('mysql:...');
/* this defaults to 'on', making every query an implicit transaction. it needs to
be off for this. you may or may not want to set this globally, or just turn it off
before this, and back on at the end. */
$dbh->setAttribute(PDO::ATTR_AUTOCOMMIT,0);
// simple best practice, ensures that SQL errors MUST be dealt with. is assumed to be enabled for the below try/catch.
$dbh->setAttribute(PDO::ATTR_ERRMODE_EXCEPTION,1);

$dbh->beginTransaction();
try {
    // the below will lock the selected row
    $select = $dbh->prepare("SELECT * FROM client_invoice_serial WHERE client_id = ? FOR UPDATE;");
    $select->execute([$client_id]);

    if( $select->rowCount() === 0 ) {
        $insert = $dbh->prepare("INSERT INTO client_invoice_serial (client_id, serial) VALUES (?, 1);");
        $insert->execute([$client_id]);
        $invoice_id = 1;
    } else {
        $invoice_id = $select->fetch(PDO::FETCH_ASSOC)['serial'] + 1;
        $update = $dbh->prepare("UPDATE client_invoice_serial SET serial = serial + 1 WHERE client_id = ?");
        $update->execute([$client_id])
    }
    $dbh->commit();
} catch(\PDOException $e) {
    // make sure that the transaction is cleaned up ASAP, then let the exception bubble up into your general error handling.
    $dbh->rollback();
    throw $e; // or throw a more pertinent error/exception of your choosing.
}
// both committing and rolling back will release the lock

在最基本的层面上,这就是 MySQL 在后台为 AUTOINCREMENT 列所做的事情。

使用MAX(id)+1。总有一天,它会咬你一口。会有两张相同编号的发票,我们会花几段来解释为什么会这样。

相反,请按预期方式使用 AUTO_INCREMENT

INSERT INTO Invoices (id, ...) VALUES (NULL, ...);
SELECT LAST_INSERT_ID();   -- specific to the conne ction

即使多个连接在做同样的事情也是安全的。不需要 FOR UPDATEBEGIN 等。 (您可能需要将其用于其他目的。)

而且,从不删除行。相反,使用使坏发票无效的标准商业惯例。想象一下被审计。

尽管如此,仍然存在潜在问题。 ROLLBACK 或系统崩溃后,一个 id 可能会被“烧掉”。还有像 INSERT IGNORE 这样的东西在检查是否需要之前分配 id。

如果您可以接受警告,请使用 AUTO_INCREMENT

如果没有,则创建一个 1 行 2 列的 table 来模拟序列号生成器:http://mysql.rjweb.org/doc.php/index_cookbook_mysql#sequence

或者使用 MariaDB 的 SEQUENCE