简介
很难想象,一个竞赛条件错误会导致某个在线服务的破产,不是吗?
在这篇文章中,我将向你展示一个竞赛条件是如何导致Flexcoin在2014年破产的。
什么是Flexcoin
Flexcoin是一个比特币数字墙,允许用户轻松接收或发送资金。
根据Wayback Machine,这就是Flexcoin应该做的事情。
使用Flexcoin,你甚至可以发送比特币到一个电子邮件地址。它是为任何人设计的,不需要技术知识就可以使用比特币。
Flexcoin是比特币基础设施的一个重要组成部分。我们的技术允许向一个用户名即时转移比特币,而不是下一个区块等待一个巨大的比特币地址。
于是,它就这样了!
Flexcoin被黑了
2014年3月2日,Flexcoin被黑了,攻击者偷走了896个比特币。Wayback Machine记录了以下公告。
2014年3月2日,Flexcoin受到攻击,热钱包中的所有硬币被抢走。攻击者抢走了896个BTC,把它们分成了这两个地址: ...
由于Flexcoin没有资源、资产或其他方面的能力从这次损失中恢复过来,我们将立即关闭我们的大门。
经过一番调查后,所有者发布了一条新的信息,描述了这次盗窃是如何策划的。
攻击者用一个新创建的用户名登录到flexcoin前端......并存入了地址......。
然后,攻击者成功地利用了代码中的一个缺陷,允许在flexcoin用户之间转账。通过同时发送数以千计的请求,攻击者能够将硬币从一个用户账户 "转移 "到另一个账户,直到发送账户被透支,余额才被更新。
在攻击者偷走了所有可用的BTC后,该公司别无选择,只能关闭该服务。这个故事表明,当并发控制策略有缺陷时,事情会变得多么可怕。
复制盗窃事件
从官方的公告中,我们可以得出结论,这次盗窃是由一个竞赛条件引起的,这是一个共享数据注册表被多个并发线程修改而没有严格的同步机制的情况。
因此,让我们尝试用以下transfer 方法来模拟这个问题。
void transfer(String fromIban, String toIban, long transferCents) {
long fromBalance = getBalance(fromIban);
if(fromBalance >= transferCents) {
addBalance(fromIban, (-1) * transferCents);
addBalance(toIban, transferCents);
}
}
getBalance 的实现方式如下。
long getBalance(final String iban) {
return doInJDBC(connection -> {
try(PreparedStatement statement = connection.prepareStatement("""
SELECT balance
FROM account
WHERE iban = ?
""")
) {
statement.setString(1, iban);
ResultSet resultSet = statement.executeQuery();
if(resultSet.next()) {
return resultSet.getLong(1);
}
}
throw new IllegalArgumentException(
"Can't find account with IBAN: " + iban
);
});
}
而且,addBalance ,像这样。
void addBalance(final String iban, long balance) {
doInJDBC(connection -> {
try(PreparedStatement statement = connection.prepareStatement("""
UPDATE account
SET balance = balance + ?
WHERE iban = ?
""")
) {
statement.setLong(1, balance);
statement.setString(2, iban);
statement.executeUpdate();
}
});
}
而我们有两个用户,Alice和Bob。
| iban | balance | owner |
|-----------|---------|-------|
| Alice-123 | 10 | Alice |
| Bob-456 | 0 | Bob |
为了验证transfer 方法,重写以下集成测试。
assertEquals(10L, getBalance("Alice-123"));
assertEquals(0L, getBalance("Bob-456"));
transfer("Alice-123", "Bob-456", 5L);
assertEquals(5L, getBalance("Alice-123"));
assertEquals(5L, getBalance("Bob-456"));
transfer("Alice-123", "Bob-456", 5L);
assertEquals(0L, getBalance("Alice-123"));
assertEquals(10L, getBalance("Bob-456"));
transfer("Alice-123", "Bob-456", 5L);
assertEquals(0L, getBalance("Alice-123"));
assertEquals(10L, getBalance("Bob-456"));
而且,当运行它时,我们可以看到它工作得很好。
- 首先,Alice向Bob发送了5美分,所以她还有
5,而Bob现在的余额是5。 - 爱丽丝第二次转账
5美分,所以她没有剩下任何美分,而鲍勃现在有10。 - 爱丽丝的第三次转账没有做任何事情,因为爱丽丝没有钱了,使状态不受影响。
然而,这个集成测试是在同一个Java线程的上下文中以串行执行的方式运行的,而Flexcoin盗窃案是使用同步并发请求完成的。
因此,让我们看看使用多个并发线程运行时,转账的效果如何。
assertEquals(10L, getBalance("Alice-123"));
assertEquals(0L, getBalance("Bob-456"));
int threadCount = 8;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
awaitOnLatch(startLatch);
transfer("Alice-123", "Bob-456", 5L);
endLatch.countDown();
}).start();
}
LOGGER.info("Starting threads");
startLatch.countDown();
LOGGER.info("Main thread waits for all transfer threads to finish");
awaitOnLatch(endLatch);
LOGGER.info("Alice's balance: {}", getBalance("Alice-123"));
LOGGER.info("Bob's balance: {}", getBalance("Bob-456"));
我们将启动一些Java线程,在同一时间执行转账。
我们正在使用两个 CountDownLatch对象来协调主线程和传输线程的执行。
- 使用
startLatch,以便所有传输线程同时启动。 - 使用
endLatch,以便主线程可以等待所有传输线程的完成。
在所有转账线程运行完毕后,我们将记录Alice和Bob的账户余额,这就是我们将得到的结果。
Alice's balance: -30
Bob's balance: 40
这可不好!
如果我们检查SQL语句日志,我们可以看到到底发生了什么。
/*
All transfer threads read Alice’s balance
*/
[Thread-5]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-3]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-2]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-7]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-0]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-4]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-1]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-6]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
/*
Since Alice’s balance is 10 and the transfer amount is 5
all transfer threads decide to do the transfer
First, the Alice’s account is debited
*/
[Thread-5]
/* Alice balance: 5 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-3]
/* Alice balance: 0 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-2]
/* Alice balance: -5 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-7]
/* Alice balance: -10 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-0]
/* Alice balance: -15 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-4]
/* Alice balance: -20 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-1]
/* Alice balance: -25 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-6]
/* Alice balance: -30 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
/*
Second, the Bob’s account is credited
*/
[Thread-5]
/* Bob balance: 5 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-3]
/* Bob balance: 10 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-2]
/* Bob balance: 15 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-7]
/* Bob balance: 20 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-0]
/* Bob balance: 25 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-4]
/* Bob balance: 30 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-1]
/* Bob balance: 35 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-6]
/* Bob balance: 40 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
现在有意义了!
下面是语句的流程。
SELECT语句是由传输线程启动后立即发出的。- 所有的转账线程将看到Alice有足够的钱来进行转账,并且
if分支将评估为真。 - 转账由所有线程启动。
- Alice的账户将被所有线程借记(例如,10-(8个线程x 5美分+ = 10-40 = -30)。
- 鲍勃的账户将被所有线程扣除(例如,0+(8个线程*5美分)=0+40=40)。
虽然关系型数据库系统提供了ACID保证,但这些保证只有在读和写是在同一个数据库事务的背景下执行时才有效。
在我们的案例中,每次传输有三个事务。
- 一个是选择Alice的账户余额
- 一个是借入Alice的账户
- 另一个是给鲍勃的账户贷记。
为什么我们每次转账有三个交易,而不是只有一个,原因是doInJDBC 方法在新获得的数据库连接中运行提供的回调。
void doInJDBC(ConnectionVoidCallable callable) {
try {
Connection connection = null;
try {
connection = dataSource().getConnection();
connection.setAutoCommit(false);
callable.execute(connection);
connection.commit();
} catch (SQLException e) {
if(connection != null) {
connection.rollback();
}
throw e;
} finally {
if(connection != null) {
connection.close();
}
}
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
<T> T doInJDBC(ConnectionCallable<T> callable) {
try {
Connection connection = null;
try {
connection = dataSource().getConnection();
connection.setAutoCommit(false);
T result = callable.execute(connection);
connection.commit();
return result;
} catch (SQLException e) {
if(connection != null) {
connection.rollback();
}
throw e;
} finally {
if(connection != null) {
connection.close();
}
}
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
转移应该在一个单一的数据库事务中执行,这样读写操作就被包裹在一个原子工作单元中了。
使用默认的事务保证来复制盗窃行为
所以,让我们改变代码,使转移在同一个数据库事务的背景下完成。
assertEquals(10L, getBalance("Alice-123"));
assertEquals(0L, getBalance("Bob-456"));
int threadCount = 8;
String fromIban = "Alice-123";
String toIban = "Bob-456";
long transferCents = 5L;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
doInJDBC(connection -> {
setIsolationLevel(connection);
awaitOnLatch(startLatch);
long fromBalance = getBalance(connection, fromIban);
if(fromBalance >= transferCents) {
addBalance(connection, fromIban, (-1) * transferCents);
addBalance(connection, toIban, transferCents);
}
});
} catch (Exception e) {
LOGGER.error("Transfer failure", e);
}
endLatch.countDown();
}).start();
}
LOGGER.info("Starting threads");
startLatch.countDown();
awaitOnLatch(endLatch);
LOGGER.info("Alice's balance: {}", getBalance("Alice-123"));
LOGGER.info("Bob's balance: {}", getBalance("Bob-456"));
这一次,转移是在一个数据库事务的上下文中完成的。
然而,当使用多个并发线程运行这个新的传输逻辑时,我们将得到以下输出。
Alice's balance: -15
Bob's balance: 25
所以,这个问题依然存在。而且,我们使用什么数据库其实并不重要。它可能是Oracle、SQL Server、PostgreSQL或MySQL。默认情况下,这个问题会发生,除非我们明确地做一些事情来防止它。
如果你看一下应用程序的日志,你会看到Alice的账户在进入负数后仍然被扣款。
[Thread-1]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-1]
/* Alice balance: 5 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-1]
/* Bob balance: 5 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-2]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-3]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-2]
/* Alice balance: 0 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-4]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-7]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-2]
/* Bob balance: 10 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-3]
/* Alice balance: -5 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-3]
/* Bob balance: 15 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-8]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-4]
/* Alice balance: -10 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-5]
/* Alice balance: -15 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-6]
SELECT balance
FROM account
WHERE iban = 'Alice-123'
[Thread-4]
/* Bob balance: 20 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
[Thread-7]
/* Alice balance: -20 */
UPDATE account
SET balance = balance + (-5)
WHERE iban = 'Alice-123'
[Thread-7]
/* Bob balance: 25 */
UPDATE account
SET balance = balance + 5
WHERE iban = 'Bob-456'
即使转账是在数据库事务的背景下进行的,这也不意味着数据库必须以可序列化的方式运行它,除非你明确告诉数据库这样做。
最顶级的关系型数据库系统的默认隔离级别是Read Committed (例如Oracle、SQL Server、PostgreSQL)或Repeatable Read (例如MySQL),而我们在这里面临的这种异常现象是任何一个系统都无法避免的。
丢失更新的异常现象
导致我们在这里面临的这种竞赛条件的异常现象被称为丢失更新,它看起来如下。


两个用户都设法读取5 的账户余额,但是第二个UPDATE 会认为它将余额从5 改为0 ,而实际上,它将余额从0 改为-5 ,因为第一个UPDATE 先设法执行。
这个流程不是可序列化的,原因是交易时间表
,将属于不同交易的读和写操作交织在一起。
由于SQL标准没有提到Lost Update异常,涉及了这个话题,所以这就是根据底层关系数据库系统的不同隔离级别来防止Lost Update异常的方法。
| Isolation Level | Oracle | SQL Server | PostgreSQL | MySQL |
|-----------------|--------|------------|------------|-------|
| Read Committed | Yes | Yes | Yes | Yes |
| Repeatable Read | N/A | No | No | Yes |
| Serializable | No | No | No | No |
所以,如果我们使用PostgreSQL,并将隔离级别改为Repeatable Read ,那么我们可以看到问题得到了解决,Bob永远不会得到超过最初Alice的账户余额。
Alice's balance: 0
Bob's balance: 10
在幕后,PostgreSQL的交易引擎通过中止会导致丢失更新异常的交易来防止这个问题。
[Thread-3]
Transfer failure - org.postgresql.util.PSQLException: ERROR: could not serialize access due to concurrent update
虽然这是防止丢失更新异常的一种方法,但也有许多其他的解决方案。