如何在多服务器系统中实现线程安全?

How do I implement Thread safety in a multi-server system?

我正在做一个项目,我必须检查数据库 table 中特定 属性 的值(如果它大于 1,则抛出异常)。必须检查每个实例。我被要求确保在多服务器设置中保持进程线程安全。因此,在数据库级别锁定是唯一可能的方法,因为数据库在所有服务器之间共享。我该如何执行? SELECT ... FOR UPDATE 如何工作?我应该担心我在哪里释放锁(即,在提交或回滚之后或之前?)

if(A.getPropertyX() > 1) {
        throw new LimitExceededException();
}

如果请求来自多个线程和单个服务器,将其保持在同步块下工作正常。但如果多个请求并行来自多个服务器,则逻辑失败。

这个问题有点像'how to build a house'——线程安全是一个非常复杂的话题。

更糟糕的是,您实际上并不是在询问线程安全性。习语 'thread safety' 本身就意味着“单一服务器”。

So lock at the DB level is the only way possible since the DB is shared between all the servers.

这是个问题;开箱即用的数据库没有锁。或者更确切地说,他们这样做了,但它不是 SQL 规范的一部分。

How does SELECT ... FOR UPDATE work?

取决于数据库,t运行saction 设置/隔离级别,并取决于数据库引擎,table 引擎。最重要的是,除了数据一致性之外,它没有其他任何保证 运行,因此尝试使用 FOR UPDATE 锁定您的应用程序 是行不通的 -从某种意义上说,它实际上可能 'work',但它依赖于如此庞大的配置和版本,如果没有大量文档,它将非常脆弱且难以理解('This works because mysql v4.5 using the innodb table engine, with the JDBC driver at v8.12 configured in this transaction isolation level, after extensive testing, indicates that FOR UPDATE freezes out the connection until committed, so we can use it for locking...' - 那种的文件。你不想要这个)。

and should I worry about where I release the lock (i.e., after or before commit or rollback?)

IF .. FOR UPDATE 锁(如果!) 'unlock' 的唯一方法是提交(你不能提交但不能解锁它。提交和解锁一起去)。当然回滚也会解锁。

But if multiple requests come parallelly from multiple servers, the logic fails. I'm supposed to throw an exception even if that's the case.

太蠢了。如果是这种情况,您应该重试。

我们来分解一下

你真正需要什么?据推测,如果所有通信都通过数据库,那么您真正需要的是一致的数据视图。你实际上根本不需要锁。这是解释我的意思的银行系统示例。

想象一下银行。在外面,有一个自动取款机。在里面,有人在办公桌前。

您打算盗取您的银行。您的帐户上有 100 美元。

你把你的银行账户卡交给你的同伙(他们也有一个账户,但是是$0),带着你的身份证件进入银行。

你让出纳员给你的同伙运行100 块钱。在同一时刻,您要求您的伙伴通过 window 进行观察,并尝试同时使用您的银行账户卡从 ATM 机中提取 10 美元纸币。

如果银行软件写的好,那么看时间你们两个可能会成功,全部搞定后状态是:

  • 您的同伙账户上有 100 美元。
  • 您的帐户中有 90 美元。
  • 您的同伙现在拿着一张 10 美元的钞票。
  • 银行刚刚被骗了 100 美元,他们永远不会知道。

大概您的任务是编写软件以防止这种情况发生 - 银行不能以这种方式被骗。

有很多方法可以解决这个问题,但最好的方法是:

  • 使用 TransactionLevel.SERIALIZABLE.
  • 使用支持重试的数据库框架。

锁是另一种解决方案,但锁是一个过时的概念,不能很好地扩展,也不是现代数据库引擎的工作方式。

什么是'SERIALIZABLE'?什么是 t运行saction 隔离级别?

t运行sactions 尝试做 gua运行tees,但它并不像 'now it is atomic' 那样简单。这些概念称为脏读和幻读。隔离级别准确地告诉数据库引擎你想要哪个 gua运行tees。 SERIALIZABLE 级别是 'heaviest' 级别并为您提供 all gua运行tees。没有脏读,没有 non-repeatable 读,没有幻读。您可以像这样在连接上设置它:

connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

不这样做是一个非常糟糕的主意:在编写您的银行软件时,试图准确地牢记您拥有和不拥有哪些 gua运行tees 是极其困难的,并且编写捕获失败的测试你的代码非常复杂。这是灾难的公式。 SERIALIZABLE 给你 gua运行tees.

MVCC的概念和重试

问题是,为了防止幻读,数据库实际上只需要在任何人做任何事情的任何时候锁定所有内容。即使是一个非常简单的 SELECT x FROM foo WHERE unid = 5; 也需要锁定 整个 foo table 才能正确制作这些 gua运行 tees,导致 dog缓慢的数据库。但是,如果您不全部锁定,那么程序员就必须使用 ... FOR UPDATE 来处理锁,这实际上仍然不能提供很好的 gua运行 tees。为了使其更适用,这里是需要防止的关键技巧 - 这就是幻读的全部内容。柜员的 'transfer money from one account to another' 软件看起来像这样:

String from = "Venkat";
String to = "Accomplice";
int amount = 100;
int fromBalance = query("SELECT balance FROM accounts WHERE user = ?", from);
int toBalance = query("SELECT balance FROM accounts WHERE user = ?", to);

if (fromBalance < amount) throw new InsufficientFunds();
fromBalance -= amount;
toBalance += amount;

update("UPDATE accounts SET balance = ? WHERE user = ?", fromBalance, from);
update("UPDATE accounts SET balance = ? WHERE user = ?", toBalance, to);

问题是,假设在 SELECT 和 UPDATE 语句之间,ATM 执行 select(注册您的帐户中有 100 美元),然后在 ATM 取款时从漏斗中取出一张 10 美元的钞票,你将 UPDATE 包裹在里面,然后最后将 ATM 'updates' 你的帐户余额设置为 90。这就是问题所在。

这里你想要的关键方面是读取是所谓的 repeatable:基本上,在你提交的那一刻,你想要 ALL SELECT 声明你 运行 在那个 t运行 行动中如果你现在再次 运行 他们有相同的结果,除了所有 UPDATE/INSERT 声明的影响你 运行 在这个非常 t运行saction.

一种方法是使用例如FOR UPDATE 或只是使用极其激进的锁定行为:让那些 SELECT 语句锁定这些行,直到你完成。

锁的问题在于它实际上不起作用。假设您执行了以下查询:

SELECT * FROM accounts where amount > 100 FOR UPDATE;

然后,当 运行 上述语句的 t运行saction 仍然打开时,其他一些 t运行saction 向数据库添加了一个新行(数量0) 并提交它,然后另一个 t运行saction 更新该行并将数量设置为 150,然后您使用 WHERE amount > 100 查询提交第一个 t运行saction。

根据您的具体需要,这 线程安全违规 - 您的 SELECT 查询未能为您提供这一行。

这解释了 SERIALIZABLE 这个名字:它的意思是:你可以想象所有与数据库的交互就好像它是全部 'serialized':首先这个 t运行saction 被启动,运行 ,关闭,然后下一个 t运行saction 开始,运行,关闭。它不必 实际上 以这种方式发生(任何打开的连接都会挂起,直到所有 t运行 操作都关闭),但 SERIALIZABLE 的要点是你可以以这种方式解释事件,所有返回的数据和更新都是这样。

如果没有它,真的很难正确编写银行软件:FOR UPDATE 东西实际上无法工作,除非你每次都锁定整个 table。这效率低得离谱。

幸运的是,这不是现代数据库的工作方式。

他们使用与您的网络电缆相同的概念:检测、重试、指数退避。

这是现代数据库引擎中实际发生的事情(您不需要 .. FOR UPDATE):

  • 在 select 和 co 上,根本没有应用任何锁。毕竟,锁很昂贵,而且 ... FOR UPDATE 容易出错(很容易忘记,也很难测试您是否需要它),所以您确实希望对所有 select 语句提供这种保护,而不仅仅是您使用 .. FOR UPDATE.
  • 亲手挑选的那些
  • 提交时,数据库会检查它为在此 t运行saction 中执行的所有查询返回的所有结果是否仍会返回相同的结果。
  • 如果是这样,太好了,提交成功。但是,如果不是这种情况,则会生成重试错误。

重试是什么意思?好吧,这句话是这样说的:你只是.. 重新开始。

这就是 ATM+Teller 情况如何通过重试概念解决(假设您有 100 美元,您的合作伙伴有 0 美元,并且您想 运行向出纳员支付 40 美元,并且从 ATM 取一张 10 美元纸币):

  • 柜员机打开一个t运行saction。
  • 柜员机获取您当前的帐户余额,以及您的合作伙伴的帐户余额 (100 - 0)。
  • 柜员机将您的余额更新为 40,将您的余额更新为 60。(尚未提交)。
  • ATM 计算机打开一个 t运行action。
  • ATM 计算机读取您的余额,仍然看到 100(毕竟柜员会话尚未提交)。
  • ATM 电脑准备好 10 块钱,准备开门。
  • 柜员机提交。
  • ATM 计算机提交 t运行 操作(想要将 90 写入您的帐户余额),但重试失败:它执行的 select 之一(即,SELECT balance FROM accounts WHERE user = you 现在 returns 其他)。所以它只是重新开始:它读取您的余额(现在是 60),减去 10,将您的帐户余额更新为 50,然后提交。这次效果不错
  • 因为t运行行动成功了,它打开门吐出10块钱。

一切正常:转运行存了40块钱,ATM机吐出10块钱,现在你的余额是50,你同事的余额是10。

而且没有任何锁!

那么如何在 java 中做到这一点?好吧,它看起来像:

public class Atm {
  void withdraw(int accountId, int amount) throws SQLException {
    while (true) {
      try (Connection con = getConnection()) {
        int balance = con.exec("SELECT balance FROM accounts WHERE...");
        if (balance < amount) throw new InsuffientFunds();
        balance -= amount;
        con.exec("UPDATE accounts SET balance = ? WHERE ....", balance);
      } catch (SQLException e) {
        // if e is retry, which depends on your DB engine...
        continue; // start over, jump to the top of while loop.
        // else..
        throw e; // actually throw the exception.
    }
  }
}

但是,计算机可能会令人讨厌地保持一致,因此如果柜员机和 ATM 计算机不断重试,它们可能永远妨碍彼此。因此,您还需要掷一些骰子并等待 运行dom 数量(并增加您在未来重复时掷的骰子的大小)。

这很烦人。事实上,JDBC 很烦人。它并不是真的可以像这样直接使用。

获取一个库来解决所有这些问题,例如 JDBI。只需遵循那个 link,就在页面顶部,解决方案就在那里:你传递一个 LAMBDA(那个 -> 东西),JDBI 将处理重新 运行 那个代码如果需要的话。

瞧!

这让你:

  • 绝对安全——你不可能从银行套路。
  • 无需仔细挑选哪些 select 需要更新,哪些不需要更新。
  • 没有锁 - 一切都会很快。
  • 您可以执行可序列化的 t运行 操作级别,从许多应用程序和许多服务器重试,所有这些都到同一个数据库引擎。

一个警告:

  • 您的数据库代码需要 'idempotent' - 运行 一次和 运行 50 次应该没有区别。这可能很难测试(幸运的是,但不像锁定的东西那么难)。