250 lines
12 KiB
Plaintext
250 lines
12 KiB
Plaintext
++ Transaction Demarcation
|
|
|
|
Transaction demarcation is the task of defining your transaction boundaries. Proper transaction demarcation is very
|
|
important because if not done properly it can negatively affect the performance of your application.
|
|
Many databases and database abstraction layers like PDO by default operate in auto-commit mode,
|
|
which means that every single SQL statement is wrapped in a small transaction. Without any explicit
|
|
transaction demarcation from your side, this quickly results in poor performance because transactions are not cheap.
|
|
|
|
For the most part, Doctrine 2 already takes care of proper transaction demarcation for you: All the write
|
|
operations (INSERT/UPDATE/DELETE) are queued until `EntityManager#flush()` is invoked which wraps all
|
|
of these changes in a single transaction.
|
|
|
|
However, Doctrine 2 also allows (and ecourages) you to take over and control transaction demarcation yourself.
|
|
|
|
These are two ways to deal with transactions when using the Doctrine ORM and are now described in more detail.
|
|
|
|
+++ Approach 1: Implicitly
|
|
|
|
The first approach is to use the implicit transaction handling provided by the Doctrine ORM
|
|
EntityManager. Given the following code snippet, without any explicit transaction demarcation:
|
|
|
|
[php]
|
|
// $em instanceof EntityManager
|
|
$user = new User;
|
|
$user->setName('George');
|
|
$em->persist($user);
|
|
$em->flush();
|
|
|
|
Since we do not do any custom transaction demarcation in the above code, `EntityManager#flush()` will begin
|
|
and commit/rollback a transaction. This behavior is made possible by the aggregation of the DML operations
|
|
by the Doctrine ORM and is sufficient if all the data manipulation that is part of a unit of work happens
|
|
through the domain model and thus the ORM.
|
|
|
|
|
|
+++ Approach 2: Explicitly
|
|
|
|
The explicit alternative is to use the `Doctrine\DBAL\Connection` API
|
|
directly to control the transaction boundaries. The code then looks like this:
|
|
|
|
[php]
|
|
// $em instanceof EntityManager
|
|
$em->getConnection()->beginTransaction(); // suspend auto-commit
|
|
try {
|
|
//... do some work
|
|
$user = new User;
|
|
$user->setName('George');
|
|
$em->persist($user);
|
|
$em->flush();
|
|
$em->getConnection()->commit();
|
|
} catch (Exception $e) {
|
|
$em->getConnection()->rollback();
|
|
$em->close();
|
|
throw $e;
|
|
}
|
|
|
|
Explicit transaction demarcation is required when you want to include custom DBAL operations in a unit of work
|
|
or when you want to make use of some methods of the `EntityManager` API that require an active transaction.
|
|
Such methods will throw a `TransactionRequiredException` to inform you of that requirement.
|
|
|
|
A more convenient alternative for explicit transaction demarcation is the use of provided control
|
|
abstractions in the form of `Connection#transactional($func)` and `EntityManager#transactional($func)`.
|
|
When used, these control abstractions ensure that you never forget to rollback the transaction or
|
|
close the `EntityManager`, apart from the obvious code reduction. An example that is functionally
|
|
equivalent to the previously shown code looks as follows:
|
|
|
|
[php]
|
|
// $em instanceof EntityManager
|
|
$em->transactional(function($em) {
|
|
//... do some work
|
|
$user = new User;
|
|
$user->setName('George');
|
|
$em->persist($user);
|
|
});
|
|
|
|
The difference between `Connection#transactional($func)` and `EntityManager#transactional($func)` is
|
|
that the latter abstraction flushes the `EntityManager` prior to transaction commit and also closes
|
|
the `EntityManager` properly when an exception occurs (in addition to rolling back the transaction).
|
|
|
|
|
|
+++ Exception Handling
|
|
|
|
When using implicit transaction demarcation and an exception occurs during `EntityManager#flush()`, the transaction
|
|
is automatically rolled back and the `EntityManager` closed.
|
|
|
|
When using explicit transaction demarcation and an exception occurs, the transaction should be rolled back immediately
|
|
and the `EntityManager` closed by invoking `EntityManager#close()` and subsequently discarded, as demonstrated in
|
|
the example above. This can be handled elegantly by the control abstractions shown earlier.
|
|
Note that when catching `Exception` you should generally rethrow the exception. If you intend to
|
|
recover from some exceptions, catch them explicitly in earlier catch blocks (but do not forget to rollback the
|
|
transaction and close the `EntityManager` there as well). All other best practices of exception handling apply
|
|
similarly (i.e. either log or rethrow, not both, etc.).
|
|
|
|
As a result of this procedure, all previously managed or removed instances of the `EntityManager` become detached.
|
|
The state of the detached objects will be the state at the point at which the transaction was rolled back.
|
|
The state of the objects is in no way rolled back and thus the objects are now out of synch with the database.
|
|
The application can continue to use the detached objects, knowing that their state is potentially no longer
|
|
accurate.
|
|
|
|
If you intend to start another unit of work after an exception has occured you should do that with a new `EntityManager`.
|
|
|
|
++ Locking Support
|
|
|
|
Doctrine 2 offers support for Pessimistic- and Optimistic-locking strategies natively. This allows to take
|
|
very fine-grained control over what kind of locking is required for your Entities in your application.
|
|
|
|
+++ Optimistic Locking
|
|
|
|
Database transactions are fine for concurrency control during a single request. However, a database transaction
|
|
should not span across requests, the so-called "user think time". Therefore a long-running "business transaction"
|
|
that spans multiple requests needs to involve several database transactions. Thus, database transactions alone
|
|
can no longer control concurrency during such a long-running business transaction. Concurrency control becomes
|
|
the partial responsibility of the application itself.
|
|
|
|
Doctrine has integrated support for automatic optimistic locking via a version field. In this approach any entity
|
|
that should be protected against concurrent modifications during long-running business transactions gets a version
|
|
field that is either a simple number (mapping type: integer) or a timestamp (mapping type: datetime). When changes
|
|
to such an entity are persisted at the end of a long-running conversation the version of the entity is compared to
|
|
the version in the database and if they dont match, an `OptimisticLockException` is thrown, indicating that the
|
|
entity has been modified by someone else already.
|
|
|
|
You designate a version field in an entity as follows. In this example we'll use an integer.
|
|
|
|
[php]
|
|
class User
|
|
{
|
|
// ...
|
|
/** @Version @Column(type="integer") */
|
|
private $version;
|
|
// ...
|
|
}
|
|
|
|
Alternatively a datetime type can be used (which maps to an SQL timestamp or datetime):
|
|
|
|
[php]
|
|
class User
|
|
{
|
|
// ...
|
|
/** @Version @Column(type="datetime") */
|
|
private $version;
|
|
// ...
|
|
}
|
|
|
|
Version numbers (not timestamps) should however be preferred as they can not potentially conflict in a highly concurrent
|
|
environment, unlike timestamps where this is a possibility, depending on the resolution of the timestamp on the particular
|
|
database platform.
|
|
|
|
When a version conflict is encountered during `EntityManager#flush()`, an `OptimisticLockException` is thrown
|
|
and the active transaction rolled back (or marked for rollback). This exception can be caught and handled.
|
|
Potential responses to an OptimisticLockException are to present the conflict to the user or to
|
|
refresh or reload objects in a new transaction and then retrying the transaction.
|
|
|
|
With PHP promoting a share-nothing architecture, the time between showing an update form and actually modifying the entity can in the worst scenario be
|
|
as long as your applications session timeout. If changes happen to the entity in that time frame you want to know directly
|
|
when retrieving the entity that you will hit an optimistic locking exception:
|
|
|
|
You can always verify the version of an entity during a request either when calling `EntityManager#find()`:
|
|
|
|
[php]
|
|
use Doctrine\DBAL\LockMode;
|
|
use Doctrine\ORM\OptimisticLockException;
|
|
|
|
$theEntityId = 1;
|
|
$expectedVersion = 184;
|
|
|
|
try {
|
|
$entity = $em->find('User', $theEntityId, LockMode::OPTIMISTIC, $expectedVersion);
|
|
|
|
// do the work
|
|
|
|
$em->flush();
|
|
} catch(OptimisticLockException $e) {
|
|
echo "Sorry, but someone else has already changed this entity. Please apply the changes again!";
|
|
}
|
|
|
|
Or you can use `EntityManager#lock()` to find out:
|
|
|
|
[php]
|
|
use Doctrine\DBAL\LockMode;
|
|
use Doctrine\ORM\OptimisticLockException;
|
|
|
|
$theEntityId = 1;
|
|
$expectedVersion = 184;
|
|
|
|
$entity = $em->find('User', $theEntityId);
|
|
|
|
try {
|
|
// assert version
|
|
$em->lock($entity, LockMode::OPTIMISTIC, $expectedVersion);
|
|
|
|
} catch(OptimisticLockException $e) {
|
|
echo "Sorry, but someone else has already changed this entity. Please apply the changes again!";
|
|
}
|
|
|
|
++++ Important Implementation Notes
|
|
|
|
You can easily get the optimistic locking workflow wrong if you compare the wrong versions.
|
|
Say you have Alice and Bob accessing a hypothetical bank account:
|
|
|
|
* Alice reads the headline of the blog post being "Foo", at optimistic lock version 1 (GET Request)
|
|
* Bob reads the headline of the blog post being "Foo", at optimistic lock version 1 (GET Request)
|
|
* Bob updates the headline to "Bar", upgrading the optimistic lock version to 2 (POST Request of a Form)
|
|
* Alice updates the headline to "Baz", ... (POST Request of a Form)
|
|
|
|
Now at the last stage of this scenario the blog post has to be read again from the database before
|
|
Alice's headline can be applied. At this point you will want to check if the blog post is still at version 1
|
|
(which it is not in this scenario).
|
|
|
|
Using optimistic locking correctly, you *have* to add the version as an additional hidden field
|
|
(or into the SESSION for more safety). Otherwise you cannot verify the version is still the one being originally read from
|
|
the database when Alice performed her GET request for the blog post. If this happens you might
|
|
see lost updates you wanted to prevent with Optimistic Locking.
|
|
|
|
See the example code, The form (GET Request):
|
|
|
|
[php]
|
|
$post = $em->find('BlogPost', 123456);
|
|
|
|
echo '<input type="hidden" name="id" value="' . $post->getId() . '" />';
|
|
echo '<input type="hidden" name="version" value="' . $post->getCurrentVersion() . '" />';
|
|
|
|
And the change headline action (POST Request):
|
|
|
|
[php]
|
|
$postId = (int)$_GET['id'];
|
|
$postVersion = (int)$_GET['version'];
|
|
|
|
$post = $em->find('BlogPost', $postId, \Doctrine\DBAL\LockMode::OPTIMISTIC, $postVersion);
|
|
|
|
+++ Pessimistic Locking
|
|
|
|
Doctrine 2 supports Pessimistic Locking at the database level. No attempt is being made to implement pessimistic locking
|
|
inside Doctrine, rather vendor-specific and ANSI-SQL commands are used to aquire row-level locks. Every Entity can
|
|
be part of a pessimistic lock, there is no special metadata required to use this feature.
|
|
|
|
However for Pessimistic Locking to work you have to disable the Auto-Commit Mode of your Database and start a
|
|
transaction around your pessimistic lock use-case using the "Approach 2: Explicit Transaction Demarcation" described
|
|
above. Doctrine 2 will throw an Exception if you attempt to aquire an pessimistic lock and no transaction is running.
|
|
|
|
Doctrine 2 currently supports two pessimistic lock modes:
|
|
|
|
* Pessimistic Write (`Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE`), locks the underlying database rows for concurrent Read and Write Operations.
|
|
* Pessimistic Read (`Doctrine\DBAL\LockMode::PESSIMISTIC_READ`), locks other concurrent requests that attempt to update or lock rows in write mode.
|
|
|
|
You can use pessimistic locks in three different scenarios:
|
|
|
|
1. Using `EntityManager#find($className, $id, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)` or `EntityManager#find($className, $id, \Doctrine\DBAL\LockMode::PESSIMISTIC_READ)`
|
|
2. Using `EntityManager#lock($entity, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)` or `EntityManager#lock($entity, \Doctrine\DBAL\LockMode::PESSIMISTIC_READ)`
|
|
3. Using `Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)` or `Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_READ)`
|
|
|