Flash sale, đúng 9 giờ. Trang sản phẩm ghi còn 5 cái. Tới 9 giờ 1 phút, kho nhận 14 đơn đã thanh toán cho đúng sản phẩm đó. Checkout nào cũng chạy trong DB::transaction(), nên team tưởng vậy là an toàn. Không phải: code đọc stock ra PHP, trừ đi một rồi ghi con số đó ngược vô. Hai transaction cùng đọc ra 5, cùng ghi 4, cùng commit mà không có lỗi nào hết.
Transaction cho mình tính nguyên tử: hoặc commit hết, hoặc không commit chút nào. Bản thân nó không làm cho các transaction chạy đồng thời cư xử như thể chạy lần lượt. Chúng thấy nhau tới mức nào là do isolation level quyết định, mà MySQL với PostgreSQL lại có default khác nhau và hành vi khác nhau dù cùng tên level. Dưới đây là mỗi level thật sự đảm bảo gì và chọn level ra sao trong Laravel.
Bug này chạy chậm lại thì trông ra sao
Đây là lost update. Không có gì cao siêu:
-- Session A -- Session B
BEGIN; BEGIN;
SELECT stock FROM products WHERE id = 7; SELECT stock FROM products WHERE id = 7;
-- app sees 5, computes 5 - 1 = 4 -- app sees 5, computes 5 - 1 = 4
UPDATE products SET stock = 4 WHERE id = 7;
COMMIT;
UPDATE products SET stock = 4 WHERE id = 7;
COMMIT;
-- Two units sold, stock went from 5 to 4.
Với REPEATABLE READ mặc định của MySQL và READ COMMITTED mặc định của PostgreSQL, kiểu xen kẽ này commit ngon lành. PostgreSQL ở REPEATABLE READ sẽ từ chối câu update của session B với lỗi could not serialize access due to concurrent update. MySQL ở REPEATABLE READ thì không, vì UPDATE của InnoDB đọc dòng mới commit nhất (gọi là "current read") chứ không đọc snapshot của transaction. Cùng tên mà kết quả khác.
Mấy kiểu đọc bất thường
Chuẩn SQL định nghĩa isolation level dựa trên việc nó cấm anomaly nào:
- Dirty read: thấy dữ liệu mà transaction khác chưa commit và có thể còn rollback.
- Non-repeatable read: đọc cùng một dòng hai lần mà ra hai giá trị khác nhau vì có người commit xen giữa.
- Phantom read: chạy cùng một query theo khoảng hai lần mà xuất hiện (hoặc biến mất) dòng mới vì có người insert hay delete dòng khớp điều kiện.
| Level | Dirty | Non-repeatable | Phantom (theo chuẩn) |
|---|---|---|---|
| READ UNCOMMITTED | có thể | có thể | có thể |
| READ COMMITTED | không | có thể | có thể |
| REPEATABLE READ | không | không | có thể |
| SERIALIZABLE | không | không | không |
Chuẩn chỉ nói cái gì không được xảy ra. Engine có quyền cấm nhiều hơn, và cả hai engine lớn đều làm vậy.
MySQL InnoDB so với PostgreSQL
MySQL InnoDB, mặc định REPEATABLE READ. SELECT thường là consistent read từ một snapshot chụp ở lần đọc đầu tiên của transaction, nên không thấy non-repeatable read hay phantom. Locking read (SELECT ... FOR UPDATE, FOR SHARE) và UPDATE/DELETE thì làm việc trên dòng mới commit nhất và giữ next-key lock (dòng cộng khoảng trống) trên đoạn index đã quét, chặn được phantom cho thao tác ghi nhưng cũng chặn insert vô mấy khoảng trống đó, một nguồn deadlock rất hay gặp. READ UNCOMMITTED thì đúng là cho dirty read. SERIALIZABLE biến SELECT thường thành SELECT ... FOR SHARE khi tắt autocommit, nên nó dựa trên lock và dễ bị chờ lock.
PostgreSQL, mặc định READ COMMITTED. Mỗi câu lệnh thấy một snapshot mới của dữ liệu đã commit. READ UNCOMMITTED được chấp nhận nhưng chạy y chang READ COMMITTED; Postgres không bao giờ cho thấy dữ liệu dơ. REPEATABLE READ là snapshot isolation: một snapshot cho cả transaction, không phantom, và nếu mình update một dòng mà người khác đã sửa sau snapshot của mình thì nhận SQLSTATE 40001 và phải retry. SERIALIZABLE thêm Serializable Snapshot Isolation (SSI): theo dõi phụ thuộc đọc/ghi và abort một transaction khi tổ hợp đó không thể xảy ra nếu chạy tuần tự. Nó không thêm lock chặn cho thao tác đọc, nhưng mình phải sẵn sàng retry.
Snapshot isolation ở cả hai engine vẫn cho phép write skew: hai transaction đọc cùng dữ kiện, mỗi bên ghi một dòng khác nhau, gộp lại thì phá vỡ ràng buộc.
-- Rule: at least one doctor must stay on call for shift 42.
-- Both transactions run under REPEATABLE READ (snapshot isolation).
-- A: SELECT COUNT(*) FROM on_call WHERE shift_id = 42; -> 2, ok to leave
-- B: SELECT COUNT(*) FROM on_call WHERE shift_id = 42; -> 2, ok to leave
-- A: DELETE FROM on_call WHERE shift_id = 42 AND doctor_id = 1; COMMIT;
-- B: DELETE FROM on_call WHERE shift_id = 42 AND doctor_id = 2; COMMIT;
-- Result: nobody on call. Different rows were written, so no write conflict.
-- PostgreSQL SERIALIZABLE detects this and aborts one of them with SQLSTATE 40001.
Chỉ có SERIALIZABLE của PostgreSQL (hoặc lock tường minh) mới chặn được chuyện này.
Cách set level cũng hơi khác nhau:
-- MySQL: applies to the NEXT transaction in this session only
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- ...
COMMIT;
-- PostgreSQL: must be the first statement inside the transaction
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;
-- Check the current level
SELECT @@transaction_isolation; -- MySQL
SHOW transaction_isolation; -- PostgreSQL
Chọn level nào
Isolation level là công cụ khá thô. Phần lớn bug về tính đúng đắn có cách sửa gọn hơn, và quyết định thường đi như vầy:
- Đọc rồi sửa một dòng (tồn kho, số dư, bộ đếm): làm phép tính ngay trong SQL bằng
UPDATE ... SET stock = stock - 1 WHERE stock >= 1nguyên tử, hoặc lock dòng bằnglockForUpdate(). Chạy đúng ở mọi level, trên cả hai engine. - Report và đọc nhiều query cần khớp nhau (tổng khớp với từng dòng chi tiết):
REPEATABLE READcho một snapshot suốt transaction. MySQL mặc định đã vậy; trên PostgreSQL thì phải xin nó. - Ràng buộc trải trên nhiều dòng khó lock (luật trực ca, đặt lịch bị trùng, "tối đa 3 session đang chạy"):
SERIALIZABLEcủa PostgreSQL kèm retry, hoặc lock dòng cha (lockshiftrồi mới đụngon_call). - OLTP lưu lượng cao trên MySQL: nhiều team đổi default của session sang
READ COMMITTEDđể giảm gap lock và deadlock, rồi dùng lock tường minh ở chỗ cần.
Cài đặt trong Laravel
Cho level tường minh và an toàn kiểu bằng một backed enum, để không ai truyền SQL tùy tiện vô câu SET:
namespace App\Database;
enum IsolationLevel: string
{
case ReadCommitted = 'READ COMMITTED';
case RepeatableRead = 'REPEATABLE READ';
case Serializable = 'SERIALIZABLE';
}
DB::transaction($callback, $attempts) của Laravel vốn đã retry khi phát hiện lỗi đồng thời (deadlock, lock wait timeout, SQLSTATE 40001). Cái nó không làm là set isolation level giùm mình. Helper dưới đây bọc chuyện đó và xử lý khác biệt giữa driver: PostgreSQL cần SET TRANSACTION là câu đầu tiên bên trong transaction, MySQL cần nó ngay trước BEGIN, và vì nó chỉ áp dụng cho transaction kế tiếp nên mỗi lần retry phải chạy lại.
namespace App\Database;
use Closure;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Throwable;
final class Isolated
{
/** SQLSTATEs that mean "safe to retry the whole transaction". */
private const RETRYABLE = ['40001', '40P01'];
/** @param Closure(ConnectionInterface): mixed $callback */
public static function run(
IsolationLevel $level,
Closure $callback,
int $attempts = 3,
?string $connection = null,
): mixed {
$db = DB::connection($connection);
for ($attempt = 1; ; $attempt++) {
try {
if ($db->getDriverName() === 'pgsql') {
return $db->transaction(function () use ($db, $level, $callback) {
// Must be the first statement of the transaction in PostgreSQL.
$db->statement('SET TRANSACTION ISOLATION LEVEL ' . $level->value);
return $callback($db);
});
}
// MySQL / MariaDB: SET TRANSACTION affects the next transaction only,
// so it has to be issued before BEGIN, on every attempt.
$db->statement('SET TRANSACTION ISOLATION LEVEL ' . $level->value);
return $db->transaction(fn () => $callback($db));
} catch (QueryException $e) {
if ($attempt >= $attempts || ! self::isRetryable($e)) {
throw $e;
}
usleep(random_int(5_000, 20_000) * $attempt);
}
}
}
public static function isRetryable(Throwable $e): bool
{
$state = $e instanceof QueryException ? ($e->errorInfo[0] ?? $e->getCode()) : $e->getCode();
return in_array((string) $state, self::RETRYABLE, true);
}
}
Rồi chọn công cụ rẻ nhất mà vẫn đúng cho từng trường hợp:
namespace App\Actions;
use App\Database\Isolated;
use App\Database\IsolationLevel;
use App\Models\OnCall;
use App\Models\Product;
use DomainException;
use Illuminate\Support\Facades\DB;
final class InventoryActions
{
/** Atomic decrement: no isolation tricks needed, the row lock does the work. */
public function sell(int $productId, int $qty): void
{
$affected = Product::query()
->whereKey($productId)
->where('stock', '>=', $qty)
->decrement('stock', $qty);
if ($affected === 0) {
throw new DomainException('Out of stock');
}
}
/** Invariant across several rows: let SERIALIZABLE catch write skew, then retry. */
public function leaveShift(int $shiftId, int $doctorId): void
{
Isolated::run(IsolationLevel::Serializable, function () use ($shiftId, $doctorId) {
$count = OnCall::query()->where('shift_id', $shiftId)->count();
if ($count <= 1) {
throw new DomainException('Someone has to stay on call');
}
OnCall::query()
->where('shift_id', $shiftId)
->where('doctor_id', $doctorId)
->delete();
}, attempts: 5);
}
}
Kiểm thử hành vi isolation
Isolation là chuyện đồng thời, nên test có ý nghĩa cần hai connection thật tới database thật. Clone config connection sang một tên thứ hai, mở transaction ở connection này, commit thay đổi qua connection kia, rồi assert connection đầu thấy gì. Đừng xài RefreshDatabase chỗ này: nó bọc test trong một transaction ở connection mặc định, che mất đúng cái hành vi mình muốn quan sát.
// tests/Feature/IsolationLevelTest.php (runs against a real PostgreSQL database)
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
config(['database.connections.pgsql_b' => config('database.connections.pgsql')]);
Schema::connection('pgsql')->dropIfExists('iso_accounts');
Schema::connection('pgsql')->create('iso_accounts', function ($table) {
$table->id();
$table->integer('balance');
});
DB::connection('pgsql')->table('iso_accounts')->insert(['id' => 1, 'balance' => 100]);
});
afterEach(function () {
DB::purge('pgsql_b');
Schema::connection('pgsql')->dropIfExists('iso_accounts');
});
function balance(string $connection): int
{
return (int) DB::connection($connection)->table('iso_accounts')->where('id', 1)->value('balance');
}
it('sees a non-repeatable read under READ COMMITTED', function () {
$a = DB::connection('pgsql');
$a->beginTransaction();
expect(balance('pgsql'))->toBe(100);
DB::connection('pgsql_b')->table('iso_accounts')->where('id', 1)->update(['balance' => 50]);
expect(balance('pgsql'))->toBe(50);
$a->rollBack();
});
it('keeps a stable snapshot under REPEATABLE READ', function () {
$a = DB::connection('pgsql');
$a->beginTransaction();
$a->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
expect(balance('pgsql'))->toBe(100);
DB::connection('pgsql_b')->table('iso_accounts')->where('id', 1)->update(['balance' => 50]);
expect(balance('pgsql'))->toBe(100);
expect(fn () => $a->table('iso_accounts')->where('id', 1)->update(['balance' => 90]))
->toThrow(QueryException::class, 'could not serialize access');
$a->rollBack();
});
Những lỗi hay gặp
- Nghĩ
DB::transaction()chặn được lost update. Không có đâu. Đọc rồi sửa trong PHP thì cầnlockForUpdate()hoặc câu update nguyên tử trong SQL. - Tưởng "REPEATABLE READ" ở đâu cũng giống nhau. Bản của MySQL cho
UPDATEthấy dữ liệu mới hơnSELECT; bản của PostgreSQL thì abort với40001. - Xài SERIALIZABLE mà không retry. Lỗi serialization là chuyện bình thường, không phải ngoại lệ hiếm. Không có vòng retry thì tải cao là ăn 500 ngẫu nhiên.
- Side effect bên trong transaction có retry. Retry chạy lại nguyên closure. Trừ tiền thẻ hay dispatch job bên trong có thể xảy ra hai lần.
- Set level sai thời điểm. Trên PostgreSQL,
SET TRANSACTIONsau bất kỳ query nào trong transaction sẽ lỗi. Trên MySQL, chạy nó trong lúc đang có transaction cũng lỗi; còn chạy một lần rồi retry thì lần retry âm thầm chạy ở level mặc định của session. - Transaction dài dưới REPEATABLE READ. Snapshot cũ giữ các phiên bản dòng cũ sống hoài (undo log của InnoDB, dead tuple của Postgres), làm phình storage và làm chậm vacuum, purge.
- Deadlock do gap lock trên MySQL.
SELECT ... FOR UPDATEtheo khoảng ởREPEATABLE READlock luôn khoảng trống. Lock theo primary key khi được, giữ thứ tự lock nhất quán, và giữ transaction ngắn.
Khi nào xài cái nào, khi nào không
Xài default khi thao tác ghi chỉ đụng một dòng và được viết bằng SQL nguyên tử, hoặc được bảo vệ bằng lockForUpdate(). Vậy là đủ cho đa số app CRUD.
Xài REPEATABLE READ cho các lần đọc nhiều câu lệnh cần khớp với nhau, như export, hóa đơn, job đối soát, nhất là trên PostgreSQL vì ở đó nó không phải default.
Xài SERIALIZABLE của PostgreSQL cho ràng buộc nghiệp vụ trải trên nhiều dòng khi lock khó làm, và chỉ khi đi kèm vòng retry cùng thân transaction idempotent.
Đừng nâng isolation level cho cả hệ thống "cho chắc ăn". Nó làm tăng abort, chờ lock và deadlock trên toàn app mà vẫn không sửa được chuyện đọc rồi sửa trong code app trên MySQL. Đừng bao giờ xài READ UNCOMMITTED cho logic nghiệp vụ.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Cài một key-value store đa phiên bản (MVCC) nhỏ xíu để coi isolation level chạy ra sao. MvccStore đã viết sẵn: begin(level) nhận 'read_committed' hoặc 'repeatable_read' (giá trị khác thì throw InvalidArgumentException / ValueError) và trả về một Transaction có snapshot là version đã commit hiện tại của store; readAt(key, maxVersion) (Python: read_at) trả về giá trị commit mới nhất có version <= maxVersion (null/None = không giới hạn) hoặc null/None; lastWriteVersion(key) (Python: last_write_version) trả về version của lần commit cuối có ghi key đó, 0 nếu chưa từng ghi; apply(writes) commit một map các thao tác ghi thành một version mới. Việc của mình là cài các method của class Transaction. read(key): nếu transaction có write đang buffer cho key thì trả về nó; còn không thì với read_committed trả về giá trị commit mới nhất, với repeatable_read trả về giá trị tại snapshot. write(key, value): chỉ buffer lại, người khác không thấy cho tới khi commit. commit(): với repeatable_read, nếu có key nào transaction này đã ghi mà lastWriteVersion lớn hơn snapshot thì bỏ hết write và throw SerializationFailure (ai commit trước thắng); với read_committed thì người ghi sau cùng thắng. Ngược lại thì apply các write đang buffer thành một lần commit, nhưng không tạo version mới khi không có write nào.
Code khởi tạo
final class SerializationFailure extends RuntimeException {}
final class MvccStore
{
/** @var array<string, list<array{int, mixed}>> key => list of [version, value] */
private array $history = [];
private int $version = 0;
public function begin(string $level): Transaction
{
if (!in_array($level, ['read_committed', 'repeatable_read'], true)) {
throw new InvalidArgumentException("Unknown isolation level: $level");
}
return new Transaction($this, $level, $this->version);
}
public function version(): int
{
return $this->version;
}
/** Newest committed value with version <= $maxVersion (null = no limit), or null. */
public function readAt(string $key, ?int $maxVersion = null): mixed
{
$found = null;
foreach ($this->history[$key] ?? [] as [$v, $value]) {
if ($maxVersion === null || $v <= $maxVersion) {
$found = $value;
}
}
return $found;
}
public function latest(string $key): mixed
{
return $this->readAt($key);
}
/** Version of the last commit that wrote $key, 0 if never written. */
public function lastWriteVersion(string $key): int
{
$rows = $this->history[$key] ?? [];
return $rows === [] ? 0 : $rows[array_key_last($rows)][0];
}
/** @param array<string, mixed> $writes */
public function apply(array $writes): void
{
$this->version++;
foreach ($writes as $key => $value) {
$this->history[$key][] = [$this->version, $value];
}
}
}
final class Transaction
{
/** @var array<string, mixed> */
private array $writes = [];
public function __construct(
private MvccStore $store,
private string $level,
private int $snapshot,
) {}
public function read(string $key): mixed
{
// TODO: return this transaction's own pending write if it has one,
// otherwise latest committed value (read_committed)
// or the value as of $this->snapshot (repeatable_read)
throw new LogicException('TODO');
}
public function write(string $key, mixed $value): void
{
// TODO: buffer the write; nothing is visible to others until commit()
throw new LogicException('TODO');
}
public function commit(): void
{
// TODO: under repeatable_read, throw SerializationFailure if any key you wrote
// was committed by someone else after your snapshot (first committer wins)
// TODO: otherwise apply the buffered writes to the store (skip if there are none)
throw new LogicException('TODO');
}
}
class SerializationFailure(Exception):
pass
class MvccStore:
def __init__(self) -> None:
self._history: dict[str, list[tuple[int, object]]] = {}
self._version = 0
def begin(self, level: str) -> "Transaction":
if level not in ("read_committed", "repeatable_read"):
raise ValueError(f"Unknown isolation level: {level}")
return Transaction(self, level, self._version)
def version(self) -> int:
return self._version
def read_at(self, key: str, max_version: int | None = None) -> object:
"""Newest committed value with version <= max_version (None = no limit), or None."""
found = None
for v, value in self._history.get(key, []):
if max_version is None or v <= max_version:
found = value
return found
def latest(self, key: str) -> object:
return self.read_at(key)
def last_write_version(self, key: str) -> int:
"""Version of the last commit that wrote key, 0 if never written."""
rows = self._history.get(key, [])
return rows[-1][0] if rows else 0
def apply(self, writes: dict[str, object]) -> None:
self._version += 1
for key, value in writes.items():
self._history.setdefault(key, []).append((self._version, value))
class Transaction:
def __init__(self, store: MvccStore, level: str, snapshot: int) -> None:
self.store = store
self.level = level
self.snapshot = snapshot
self.writes: dict[str, object] = {}
def read(self, key: str) -> object:
# TODO: return this transaction's own pending write if it has one,
# otherwise latest committed value (read_committed)
# or the value as of self.snapshot (repeatable_read)
raise NotImplementedError
def write(self, key: str, value: object) -> None:
# TODO: buffer the write; nothing is visible to others until commit()
raise NotImplementedError
def commit(self) -> None:
# TODO: under repeatable_read, raise SerializationFailure if any key you wrote
# was committed by someone else after your snapshot (first committer wins)
# TODO: otherwise apply the buffered writes to the store (skip if there are none)
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýTrong read() coi buffer write trước; transaction lúc nào cũng phải thấy thay đổi của chính nó
Khác biệt duy nhất giữa hai level khi đọc là maxVersion truyền vô readAt: snapshot hoặc không giới hạn
Kiểm tra xung đột trong commit() chỉ xét key mà transaction này đã ghi, không xét key đã đọc
final class SerializationFailure extends RuntimeException {}
final class MvccStore
{
/** @var array<string, list<array{int, mixed}>> key => list of [version, value] */
private array $history = [];
private int $version = 0;
public function begin(string $level): Transaction
{
if (!in_array($level, ['read_committed', 'repeatable_read'], true)) {
throw new InvalidArgumentException("Unknown isolation level: $level");
}
return new Transaction($this, $level, $this->version);
}
public function version(): int
{
return $this->version;
}
/** Newest committed value with version <= $maxVersion (null = no limit), or null. */
public function readAt(string $key, ?int $maxVersion = null): mixed
{
$found = null;
foreach ($this->history[$key] ?? [] as [$v, $value]) {
if ($maxVersion === null || $v <= $maxVersion) {
$found = $value;
}
}
return $found;
}
public function latest(string $key): mixed
{
return $this->readAt($key);
}
/** Version of the last commit that wrote $key, 0 if never written. */
public function lastWriteVersion(string $key): int
{
$rows = $this->history[$key] ?? [];
return $rows === [] ? 0 : $rows[array_key_last($rows)][0];
}
/** @param array<string, mixed> $writes */
public function apply(array $writes): void
{
$this->version++;
foreach ($writes as $key => $value) {
$this->history[$key][] = [$this->version, $value];
}
}
}
final class Transaction
{
/** @var array<string, mixed> */
private array $writes = [];
public function __construct(
private MvccStore $store,
private string $level,
private int $snapshot,
) {}
public function read(string $key): mixed
{
if (array_key_exists($key, $this->writes)) {
return $this->writes[$key];
}
return $this->level === 'repeatable_read'
? $this->store->readAt($key, $this->snapshot)
: $this->store->readAt($key);
}
public function write(string $key, mixed $value): void
{
$this->writes[$key] = $value;
}
public function commit(): void
{
if ($this->level === 'repeatable_read') {
foreach (array_keys($this->writes) as $key) {
if ($this->store->lastWriteVersion((string) $key) > $this->snapshot) {
$this->writes = [];
throw new SerializationFailure("could not serialize access to $key");
}
}
}
if ($this->writes !== []) {
$this->store->apply($this->writes);
}
$this->writes = [];
}
}
class SerializationFailure(Exception):
pass
class MvccStore:
def __init__(self) -> None:
self._history: dict[str, list[tuple[int, object]]] = {}
self._version = 0
def begin(self, level: str) -> "Transaction":
if level not in ("read_committed", "repeatable_read"):
raise ValueError(f"Unknown isolation level: {level}")
return Transaction(self, level, self._version)
def version(self) -> int:
return self._version
def read_at(self, key: str, max_version: int | None = None) -> object:
"""Newest committed value with version <= max_version (None = no limit), or None."""
found = None
for v, value in self._history.get(key, []):
if max_version is None or v <= max_version:
found = value
return found
def latest(self, key: str) -> object:
return self.read_at(key)
def last_write_version(self, key: str) -> int:
"""Version of the last commit that wrote key, 0 if never written."""
rows = self._history.get(key, [])
return rows[-1][0] if rows else 0
def apply(self, writes: dict[str, object]) -> None:
self._version += 1
for key, value in writes.items():
self._history.setdefault(key, []).append((self._version, value))
class Transaction:
def __init__(self, store: MvccStore, level: str, snapshot: int) -> None:
self.store = store
self.level = level
self.snapshot = snapshot
self.writes: dict[str, object] = {}
def read(self, key: str) -> object:
if key in self.writes:
return self.writes[key]
if self.level == "repeatable_read":
return self.store.read_at(key, self.snapshot)
return self.store.read_at(key)
def write(self, key: str, value: object) -> None:
self.writes[key] = value
def commit(self) -> None:
if self.level == "repeatable_read":
for key in self.writes:
if self.store.last_write_version(key) > self.snapshot:
self.writes = {}
raise SerializationFailure(f"could not serialize access to {key}")
if self.writes:
self.store.apply(self.writes)
self.writes = {}

