Apache Ignite Transaction Architecture: Concurrency Models and Isolation Levels

In the first article of this series , we looked at the 2-phase commit protocol and how Ignite handles various types of cluster nodes. Here are the topics to cover in the remaining articles:

  • Concurrency Models and Isolation Levels
  • Failover and Recovery
  • Transaction processing (WAL, checkpointing, and others) in the Ignite persistence layer;
  • Transaction Processing in Third-Party Persistence

In this article, we'll focus on concurrency models and isolation levels. Most modern multi-user applications allow concurrent data access and modification. To manage this functionality and ensure that the system switches from one consistent state to another, the concept of transactions is used. Transactions rely on locks, which can be acquired at the beginning of the transaction (pessimistic locking) or before committing at the end of the transaction (optimistic locking). Ignite supports two concurrency models: pessimistic and optimistic . Let's talk about the pessimistic concurrency model first.

Pessimistic Concurrency Model

An example of a pessimistic concurrency model is a transfer between two bank accounts that needs to ensure that the debit and credit status of both bank accounts are properly recorded. At this point, both accounts need to be locked to ensure that the update is fully completed and the balance is correct. In a pessimistic concurrency model, the application needs to lock all data that is about to be read, written, or modified at the beginning of the transaction. Ignite also supports a set of isolation levels for a pessimistic concurrency model , providing flexibility when reading and writing data:

  • read commit
  • repeatable read
  • Serialization

In the read-commit model, the lock is acquired before the write operation makes any changes to the data, such as put() or putAll() , while the repeatable read and serialization models are used in scenarios where both read and write operations need to acquire locks. Ignite also has some built-in features that make debugging and solving distributed deadlock problems easier.

The following code example shows a repeatable read pessimistic transaction because the application needs to read and write to a specific bank account:

try (Transaction tx = Ignition.ignite().transactions().txStart(PESSIMISTIC, REPEATABLE_READ)) {
    Account acct = cache.get(acctId);

    assert acct != null;

    ...

    // Deposit into account.
    acct.update(amount);

    // Store updated account in cache.
    cache.put(acctId, acct);

    tx.commit();
}

In this example, the transaction is opened and committed through the txStart() and tx.commit() methods, respectively. The txStart() method is passed the PESSIMISTIC and REPEATABLE READ parameters, in the try block, the code performs a cache.get() operation on the acctId key, after that, some funds are deposited into the account and the cache is cached using cache.put() renew.

The following code example shows a read-committed pessimistic transaction with deadlock handling:

try (Transaction tx = ignite.transactions().txStart(TransactionConcurrency.PESSIMISTIC, TransactionIsolation.READ_COMMITTED, TX_TIMEOUT, 0)) {

    // More code here.

    tx.commit();
} catch (CacheException e) {
    if (e.getCause() instanceof TransactionTimeoutException &&
        e.getCause().getCause() instanceof TransactionDeadlockException)

        System.out.println(e.getCause().getCause().getMessage());
}

In this example, the code shows how to use Ignite's deadlock detection mechanism , which simplifies debugging of distributed deadlocks that may be caused by application code. To enable this feature, you need to start an Ignite transaction with a non-zero timeout (TX_TIMEOUT > 0), and you need to catch the TransactionDeadlockException with the deadlock details.

Let's take a look at the message flow of different isolation levels. For read submission, as shown in Figure 1, in this isolation model, Ignite does not acquire locks for read operations, such as get() or getAll() , which may be useful for many scenarios. More suitable.

Figure 1: Read Commit

  1. transaction start ( 1 tx.Start );
  2. The transaction coordinator manages transaction requests internally ( 2 IgniteInternalTx );
  3. Apply write keys K1 and K2 ( 3 tx.putAll(K1-V1, K2-V2) );
  4. The transaction coordinator writes K1 to the local transaction map ( 4 Put(K1) );
  5. The transaction coordinator initiates a lock request to the master node storing K1 ( 5 lock(K1) );
  6. The master node manages transaction requests internally ( 6 IgniteInternalTx );
  7. The master sends a ready acknowledgment ( 7 ACK ) to the transaction coordinator;
  8. Repeat steps 4-7 in Figure 1 for K2;
  9. Initiate a transaction commit request ( 12 tx.commit );
  10. K1 and K2 write to the corresponding master nodes ( 13 Write(K1) and 13 Write(K2) );
  11. The master node confirms the transaction commit ( 14 ACK );

Next, take a look at the repeatable read and serializable message flow, as shown in Figure 2:

Figure 2: Repeatable Read and Serialization

  1. transaction start ( 1 tx.Start );
  2. The transaction coordinator manages transaction requests internally ( 2 IgniteInternalTx );
  3. App reads keys K1 and K2 ( 3 tx.getAll(K1-V1, K2-V2) );
  4. The transaction coordinator starts the read request processing for key K1 ( 4 Get(K1) );
  5. The transaction coordinator initiates a lock request to the master node storing K1 ( 5 lock(K1) );
  6. The master node manages transaction requests internally ( 6 IgniteInternalTx );
  7. The master node sends a ready acknowledgment ( 7 ACK ) to the transaction coordinator and returns the value of K1;
  8. Repeat steps 4-7 in Figure 2 for K2;
  9. The application writes to K1 and K2 ( 12 tx.putAll(K1-V2, K2-V2) );
  10. The transaction coordinator writes the update of K1 to the local transaction map ( 13 Put(K1) );
  11. The transaction coordinator writes K2's update to the local transaction map ( 14 Put(K2) );
  12. Initiate a transaction commit request ( 15 tx.commit );
  13. K1 and K2 write to the corresponding master nodes ( 16 Write(K1) and 16 Write(K2) );
  14. The master node confirms the transaction commit ( 17 ACK );

To summarize, in the pessimistic model, the lock is held until the transaction completes, and the lock prevents other transactions from accessing the data. The next step is to look at the optimistic concurrency model.

optimistic concurrency model

An example of an optimistic concurrency model is computer-aided design (CAD), where a designer works on a portion of the overall design, typically checking the design out from a central repository to a local workspace, and then checking the work in to the central after making some updates Warehouse, because the designer is only responsible for one part of the whole design, it is impossible to conflict with the update of other parts.

In contrast to the pessimistic concurrency model, the optimistic concurrency model delays lock acquisition and is thus more suitable for applications with less resource contention, such as the CAD example described above. Ignite also supports some isolation levels for optimistic concurrency models , which provide flexibility in reading and writing data:

  • read commit
  • repeatable read
  • Serialization ( no deadlock )

Recalling the previous discussion of the phases in a 2-phase commit, when using the optimistic concurrency model, locks are acquired at the master node during the prepare phase. When using serialization mode, the transaction will fail during the prepare phase if the data requested by the transaction has changed. At this time, the developer needs to programmatically control the behavior of the application, that is, whether the transaction needs to be restarted. The other two modes, Repeatable Read and Read Commit, do not check whether the data has changed. While this brings performance benefits, there is no guarantee of atomicity of the data, so these two patterns are rarely used in production.

The following code example shows a serialized optimistic transaction because the application requires read and write operations to a specific bank account:

while (true) {
    try (Transaction tx = ignite.transactions().txStart(TransactionConcurrency.OPTIMISTIC, TransactionIsolation.SERIALIZABLE)) {

        Account acct = cache.get(acctId);

        assert acct != null;

        ...

        // Deposit into account.
        acct.update(amount);

        // Store updated account in cache.
        cache.put(acctId, acct);

        tx.commit();

        // Transaction succeeded. Exiting the loop.
        break;
    } catch (TransactionOptimisticException e) {
        // Transaction has failed. Retry.
    }
}

In this example, there is a while loop on the outside that determines if the transaction fails and it can retry. Next, there are txStart() and tx.commit() methods for transaction start and commit, respectively. The txStart() method passes the OPTIMISTIC and SERIALIZABLE parameters. In the try block, the code first performs a cache.get() operation on the acctId key, after which some funds are deposited into the account and the cache is updated using cache.put() . If the transaction is successful, the code breaks from the loop, if the transaction is unsuccessful, an exception is thrown and the transaction is retried. For optimistic serialized transactions, the order of access keys is not restricted because Ignite acquires transaction locks in parallel with an additional check to avoid deadlocks .

Let's take a look at the message flow under different isolation levels, starting with serialization, as shown in Figure 3:

Figure 3: Serialization

  1. transaction start ( 1 tx.Start );
  2. The transaction coordinator manages transaction requests internally ( 2 IgniteInternalTx );
  3. Apply write key K1 ( 3 tx.put(K1-V1) );
  4. The transaction coordinator writes K1 to the local transaction map ( 4 Put(K1) );
  5. Apply write key K2 ( 5 tx.put(K2-V2) );
  6. The transaction coordinator writes K2 to the local transaction map ( 6 Put(K2) );
  7. Initiate a transaction commit request ( 7 tx.commit );
  8. The transaction coordinator initiates a lock request to the master node storing K1 and K2 ( 8 lock(K1, TV1) and 8 lock(K2, TV1) );
  9. The master manages transaction requests internally ( 9 IgniteInternalTx );
  10. The master sends a ready acknowledgment ( 10 ACK ) to the transaction coordinator;
  11. K1 and K2 write to the corresponding master nodes ( 11 Write(K1) and 11 Write(K2) );
  12. If there is no data conflict (ie, K1 and K2 are not updated by other applications), the master node confirms the transaction commit ( 12 ACK ).

Finally, take a look at the message flow for repeatable reads and read commits, as shown in Figure 4:

Figure 4: Repeatable reads and read commits

  1. transaction start ( 1 tx.Start );
  2. The transaction coordinator manages transaction requests internally ( 2 IgniteInternalTx );
  3. Apply write key K1 ( 3 tx.put(K1-V1) );
  4. The transaction coordinator writes K1 to the local transaction map ( 4 Put(K1) );
  5. Apply write key K2 ( 5 tx.put(K2-V2) );
  6. The transaction coordinator writes K2 to the local transaction map ( 6 Put(K2) );
  7. Initiate a transaction commit request ( 7 tx.commit );
  8. The transaction coordinator initiates a lock request to the master node storing K1 and K2 ( 8 lock(K1, TV1) and 8 lock(K2, TV1) );
  9. The master sends a ready acknowledgment ( 9 ACK ) to the transaction coordinator;
  10. K1 and K2 write to the corresponding master nodes ( 10 Write(K1) and 10 Write(K2) );
  11. The master node manages transaction requests internally ( 11 IgniteInternalTx );
  12. The master node acknowledges the transaction commit ( 12 ACK ).

Summarize

In this article, we looked at the main lock models and isolation levels supported by Ignite, and we saw that there is a lot of flexibility and choice, and later in this series, we'll look at failover and recovery.

This article is translated from the blog of GridGain technical evangelist Akmal B. Chaudhri .

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=324456127&siteId=291194637