All about Two-Phase Locking and a little bit MVCC
Join the DZone community and get the full member experience.
Join For FreeIn this blog I will describe the concurrency control methods implemented in database management systems, and the differences between them. I will also explain about what locking technique is used in CUBRID RDBMS, about locking modes and their compatibility, and finally, the deadlocks and the solution for them.
Overview
When multiple transactions, which change the data, are executed simultaneously, it is required to control the order of processing these transactions to satisfy the ACID (Atomicity, Consistency, Integrity, Durability) property of the database. Executing multiple transactions simultaneously should lead to the same result as executing each transaction independently, in other words, one transaction should not be affected by another transaction.
If different data is changed for each transaction, no interference between transactions is made, so there is no issue. However, if the same data is simultaneously changed by multiple transactions, the order of processing each transaction should be controlled.
Types of Concurrency Control
For example, the T1 transaction changes the A record from 1 to 2 and then changes the B record, the T2 transaction can simultaneously change the A record, too. Let's assume that the T2 transaction changes the A record from 2 to 4 by adding +2. If two transactions are successfully terminated, there is no issue. But it is important that all transactions can be rolled back. If the T1 transaction is rolled back, the value of the A record should be returned to 1, i.e. the value before the T1 transaction was executed. This is to satisfy the ACID property of the database. However, the T2 transaction has already changed the A record value to 3. So, it is impossible to return the A record to 1 regardless of the situation. In this case, there can be two options.
- Two-phase locking (2PL)
The first one is when the T2 transaction tries to change the A record, it knows that the T1 transaction has already changed the A record and waits until the T1 transaction is completed because the T2 transaction cannot know whether the T1 transaction will be committed or rolled back. This method is called Two-phase locking (2PL). - Multi-version concurrency control (MVCC)
The other one is to allow each of them, T1 and T2 transactions, to have their own changed versions. Even when the T1 transaction has changed the A record from 1 to 2, the T1 transaction leaves the original value 1 as it is and writes that the T1 transaction version of the A record is 2. Then, the following T2 transaction changes the A record from 1 to 3, not from 2 to 4, and writes that the T2 transaction version of the A record is 3.
When the T1 transaction is rolled back, it does not matter if the 2, the T1 transaction version, is not applied to the A record. After that, if the T2 transaction is committed, the 3, the T2 transaction version, will be applied to the A record. If the T1 transaction is committed prior to the T2 transaction, the A record is changed to 2, and then to 3 at the time of committing the T2 transaction. The final database status is identical to the status of executing each transaction independently, without any impact on other transactions. Therefore, it satisfies the ACID property. This method is called Multi-version concurrency control (MVCC).
CUBRID has implemented 2PL method as well as DB2 and SQL Server, while Oracle, InnoDB and PostgreSQL have implemented MVCC.
Two-phase locking in CUBRID
The 2PL adopted by CUBRID uses locks to ensure the consistency between transactions that change the identical data. As the "lock" literally means, the locking is executed through two phases:
- expanding phase (acquiring)
- shrinking phase (releasing)
More accurately, all transactions should acquire lock for the data to be accessed and the acquired locks are released only when the transaction is terminated. After a transaction has acquired the lock for a certain data (regardless of the lock type, S_LOCK
for read, stands for Shared Lock, or X_LOCK
for write, stands for Exclusive Lock), when another transaction tries to acquire a new lock for the data, the new lock is allowed or pended depending on the lock compatibility rule. Therefore, success or failure of the prior transaction does not have impact on the following transactions, so the data consistency is maintained.
Lock Manager in CUBRID
Thus, the key point of 2PL, adopted by CUBRID, is that the lock must be processed through two phases: expanding phase and shrinking phase. Then, [Figure 1] release all locks, acquired while executing a transaction, only after the transaction ends (commit or rollback).
Figure 1: Two-Phase Locking.
2PL concurrency control method naturally controls access to the identical data from transactions by making all transactions observe the 2PL protocol. The following Figure 2 below shows an example of three transactions using 2PL: Transaction 1 executes B=B+A operation, Transaction 2 executes C=A+B operation, and Transaction 3 executes Print C operation. Since all three transactions are accessing the data A, B and C, the concurrency control is required. In this case, each transaction is executed according to the 2PL protocol so that there is no data conflict.
Figure 2: Concurrency Control by using 2PL.
Lock modes
To understand the concurrency control of multiple transactions more deeply, let's discuss about lock modes, lock conversion and transaction isolation level. In the above figure, you can see that S-lock
, Shared Lock, for A was first acquired by Transaction 1, but it is also acquired by Transaction 2, too. On the contrary, the transaction which requested X-lock
is blocked until S-lock
is released. In this matter, a variety of lock modes are used to minimize conflicts by lockers. Major types of locks utilized in DBMSs are.
- Shared (S) Lock: Used for read operation. It is generally set on the target record when
SELECT
statement is executed. It blocks a transaction from changing data which was already read by other transactions. - Exclusive (X) Lock: Used for write-operations such as
INSERT
,UPDATE
,DELETE
. It blocks one data from being changed by multiple transactions. - Update (U) Lock: Used to define that the target resource will be changed. It is used to minimize deadlock which may occur when multiple transactions are executing both read and write.
- Intent Shared (IS) Lock: Set on the upper resource (e.g. tables) to set the
S-lock
on some lower resources (e.g. records or pages). It is to prevent other transactions from settingX-lock
on the upper resource. Intent lock will soon be described. - Intent Exclusive (IX) Lock: Set on the upper resource to set
X-lock
on some lower resources. - Shared with Intent Exclusive (SIX) Lock: Set on the upper resource to set
S-lock
andX-lock
on some lower resources.
Lock mode compatibility
Among the lock modes above, intent locks are used to improve the transaction concurrency and to prevent deadlock between the upper resources and the lower resources. For example, when Transaction A tries to read Record R on Table T, it sets IS_LOCK
on Table T before setting S_LOCK
on Record R. Then, Transaction B is prevented from setting X_LOCK
on Table T to change the structure of Table T. If Transaction A has not set IS_LOCK
on Table T, Transaction B would change the structure of Table T. Then, Transaction A would perform a wrong read operation. This way Transaction B has no need to check all records in Table T to check whether there is any lock set by other transactions for setting X_LOCK
on Table T. The following lock mode compatibility table will clearly show the effect of intent locks:
Current Lock Mode | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|
NULL | IS | NS | S | IX | SIX | U | NX | X | ||
Newly-requested Lock Mode | NULL | True | True | True | True | True | True | True | True | True |
IS | True | True | N/A | True | True | True | N/A | N/A | False | |
NS | True | N/A | True | True | N/A | N/A | False | True | False | |
S | True | True | True | True | False | False | False | False | False | |
IX | True | True | N/A | False | True | False | N/A | N/A | False | |
SIX | True | True | N/A | False | False | False | N/A | N/A | False | |
U | True | N/A | True | True | N/A | N/A | False | False | False | |
NX | True | N/A | True | False | N/A | N/A | False | False | False | |
X | True | False | False | False | False | False | False | False | False |
From the lock mode compatibility table, you can see that X_LOCK
cannot be set on a table if IS_LOCK
is set on the table. And only IS_LOCK
can be compatible with SIX_LOCK
. This means that SIX_LOCK
intends to set S_LOCK
and X_LOCK
on the record and it will not allow any lock but IS_LOCK
for S_LOCK
on other non-conflicting records. From the table, you can see that IX_LOCK
and IX_LOCK
can be compatible with each other. IX_LOCK
intends to set X_LOCK
for some records. So, the compatibility is available. If there are two transactions that try to change an identical record, IX_LOCK
for the table is allowed. However, there is no problem in concurrency control since only the transaction that has acquired X_LOCK
for the record first can change the record (X_LOCK
and X_LOCK
are not compatible).
The lock mode compatibility table is expressed as a global variable lock_Comp[][]
in the lock_table.c file in CUBRID source code. Among CUBRID sources, most codes related to lock modes are implemented in lock_manager.c file. To set lock on a data object, the lock_object()
function is used which receives three parameters: the OID of an object where the lock mode will be set, the OID of the class where the object belongs, and the desired lock mode. In the source code of the function, you can see that the function is executed in several ways based on the target of the lock mode, the lock mode for an instance object or for a class object.
Take note of this: in CUBRID, a class object is also an object. Keep it in mind that a class object has an OID and all class objects are the instances of a root class, so it uses ROOTOID, the OID of the root object, as its class OID.
From the code, you can see that the required intent lock is set on a class object when a lock mode is required for an instance object. And there is a concept of lock waiting time in the lock mode request. To retrieve the lock timeout value set on the current transaction, the logtb_find_wait_secs()
function is called. CUBRID supports the SET TRANSACTION LOCK TIMEOUT
SQL command and the setLockTimeout()
method in JDBC. The command is to specify the lock timeout of the current transaction. Lock waiting time means the time for a transaction, which has made a request for lock mode, to wait when a lock mode is set on an object by a transaction and the requested lock is not compatible with the already-set lock mode. As you have seen before, the 2PL concurrency control method does not allow lock from other transactions until the existing lock is released. For the following two reasons, lock timeout should be set by a transaction:
- When a user does not want to wait too long because of the lock mode.
- To lower the frequency of deadlock.
Deadlocks
A deadlock occurs when two or more transactions request resources locked by each of them, so all transactions cannot be progressed. Figure 8 below shows an example of a deadlock.
Figure 2: Transaction Deadlock.
First, Transaction 1 executes UPDATE participant SET gold=10 WHERE host_year=2004 AND nation_code=’KOR’
statement and sets X_LOCK
on the ‘KOR’
record. Transaction 2 sets X_LOCK
on the ‘JPN’
record. Transaction 3 sets X_LOCK
on the ‘CHN’
record.
After that, Transaction 1 requests X_LOCK
on the ‘JPN’
record for executing UPDATE
for that record. However, the ‘JPN’
record is already locked with X_LOCK
by Transaction 2. So, Transaction 1 should wait until Transaction 2 ends. Based on the 2PL protocol, the X_LOCK
is released when the transaction ends. Transaction 2 requests X_LOCK
on the ‘CHN’
record and waits for Transaction 3. Finally, Transaction 3 waits for Transaction 1 to acquire the 'KOR'
record of Transaction 1 as it has X_LOCK
on the ‘CHN’
record. As a result,Transaction 1 waits for Transaction 2 to end, Transaction 2 waits for Transaction 3 to end, and Transaction 3 waits for Transaction 1 to end. So, no transaction can be progressed. This is called a deadlock.
Most DBMSs which use the 2PL method, including CUBRID, use the deadlock detection method to solve the deadlock problem. It periodically checks whether the cycle illustrated in the above figure occurs by drawing a Lock Wait Graph for the transactions being executed.
In CUBRID, the thread for detecting deadlock checks the Lock Wait Graph every second. When a deadlock is detected, one transaction among the transactions is randomly selected and aborted by force. This is called unilateral abort. When a transaction is selected as a victim to be sacrificed to solve the deadlock and unilaterally aborted, the corresponding SQL statement returns an error code. The error message is "The transaction has timed out due to deadlock while waiting for X_LOCK
for an object. It waited until User 2 ended.” When an error is returned and the application aborts the transaction, the locks of the transaction are released and other transactions can be continuously processed.
To see how the deadlock is detected, see the lock_detect_local_deadlock()
function in the source code. This function is called with the intervals (in seconds) specified by the PRM_LK_RUN_DEADLOCK_INTERVAL
variable (the deadlock_detection_interval_in_secs
parameter in cubrid.conf file) on the background thread which executes thread_deadlock_detect_thread()
.
Even if a deadlock does not occur, when the execution time of a transaction is too long, other transactions should wait for too long as well. For a certain application, it is wiser to give up rather than wait. In particular, when a web server has called DB tasks and the wait time is too long, all threads of the web server are used to process the DB, so they cannot be used to process external HTTP requests any more, causing service failures. Therefore, for a web application, the threads should be returned without waiting an unlimited amount of time for DB processing even if an error occurs. Two methods are used for that:
- One is lock timeout supported by CUBRID.
- The other is query cancel. JDBC is defined with an API which can cancel the SQL statement being executed.
The key data structure of the lock manager is defined in the lock_manager.c file.
typedef struct lk_entry LK_ENTRY; struct lk_entry { #if defined(SERVER_MODE) struct lk_res *res_head; /* back to resource entry */ THREAD_ENTRY *thrd_entry; /* thread entry pointer */ int tran_index; /* transaction table index */ LOCK granted_mode; /* granted lock mode */ LOCK blocked_mode; /* blocked lock mode */ int count; /* number of lock requests */ struct lk_entry *next; /* next entry */ struct lk_entry *tran_next; /* list of locks that trans. holds */ struct lk_entry *class_entry; /* ptr. to class lk_entry */ LK_ACQUISITION_HISTORY *history; /* lock acquisition history */ LK_ACQUISITION_HISTORY *recent; /* last node of history list */ int ngranules; /* number of finer granules */ int mlk_count; /* number of instant lock requests */ unsigned char scanid_bitset[1]; /* PRM_LK_MAX_SCANID_BIT/8]; */ #else /* not SERVER_MODE */ int dummy; #endif /* not SERVER_MODE */ }; typedef struct lk_res LK_RES; struct lk_res { MUTEX_T res_mutex; /* resource mutex */ LOCK_RESOURCE_TYPE type; /* type of resource: class,instance */ OID oid; OID class_oid; LOCK total_holders_mode; /* total mode of the holders */ LOCK total_waiters_mode; /* total mode of the waiters */ LK_ENTRY *holder; /* lock holder list */ LK_ENTRY *waiter; /* lock waiter list */ LK_ENTRY *non2pl; /* non2pl list */ LK_RES *hash_next; /* for hash chain */ };
From the file, the lk_Gl
global variable of LK_GLOBAL_DATA
type is the core. The LK_ENTRY
structure stands for the lock itself. For example, when the Transaction T1 has requested a lock, one LK_ENTRY
is created. LK_RES
is a structure that shows to which resource the lock belongs. In CUBRID, all resources are objects (instance objects and class objects), so they are shaped as OIDs. In the LK_RES
structure, you can see the list of holders with LK_ENTRY
type and the list of waiters. The list of holders is a list of transactions that hold the lock for the resource now. For example, when Transaction T1 and Transaction T2 have acquired S_LOCK
for the data record with OID1, LK_ENTRY
that corresponds to the S_LOCK
of T1 and T2 will be registered in the list of holders. When Transaction T3 requests X_LOCK
on the OID1 record, T3 should wait because of the existing S_LOCK
. So, the LK_ENTRY
corresponding to X_LOCK
of T3 will be registered to the list of waiters. Which lock is held by which transaction is maintained in the tran_lock_table
variable which has the LK_TRAN_LOCK
structure as a table.
The Wait For Graph for detecting a deadlock is expressed as TWFG_node
and TWFG_edge
of the LK_WFG_NODE
structure and the LK_WFG_EDGE
structure. The lock_detect_local_deadlock()
function creates a Wait For Graph and detects whether there is a cycle on the graph. When a cycle is detected, the lock_select_deadlock_victim()
function selects a victim transaction to be sacrificed for solving the deadlock. For reference, transactions are continuously executed while a Wait For Graph is drawn up and checked, the information of the ended transaction is removed from the graph. The victim transaction is selected based on the following criteria:
- If a transaction is not a holder, it cannot be a victim.
- When a transaction is in the commit phase or the rollback phase, it cannot be selected as a victim.
- Select a transaction of which lock timeout is not set to -1 (unlimited waiting) first.
- Select the latest transaction rather than the older one. (The transaction ID is an incremental number. A transaction with smaller transaction number is the older one.)
Conclusion
This concludes the talk about Two-Phase Locking in CUBRID. I briefly covered the types of concurrency control, the difference between 2PL and MVCC, about what locking technique is used in CUBRID RDBMS, about locking modes and their compatibility, and finally, the deadlocks and the solution for them.
In this article I have mentioned about OID (Object Identifiers) which are used to identify instance objects as well as class objects. In the next article I will continue this talk and explain what Object, Class, and OID are.
Published at DZone with permission of Esen Sagynov, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments