فهرست مقاله
دو بار خواندن یک جدول، الزاماً دو مشاهده از تازهترین داده نیست. در یک گزارش مهندسی، ممکن است خواننده عمداً همان وضعیت قبلی را نگه دارد، درحالیکه اتصال نویسنده تغییر تازهای ثبت کرده است. این درس با دو اتصال واقعی به یک پایگاه دادهٔ موقت، مرز این رفتار را در SQLite بررسی میکند. عملیات را ترتیبی و قابلردیابی اجرا میکنیم؛ رشتهٔ اجرایی همزمان، زمانسنج، شبکه یا آزمون ظرفیت نداریم. اعداد ولتاژ نیز ساختگیاند.
۱. یک فایل مشترک، دو اتصال و تنظیم روشن

کد کنار فایل خودش پوشهای تازه و اختصاصی میسازد و فقط در همان پوشه، fixture.sqlite را ایجاد میکند. reader و writer دو اتصال جدا با cache=private به همین فایلاند؛ دو کپی مستقل نیستند. جدول آغازین، نمونهٔ یک با هزار و نمونهٔ دو با دوهزار میلیولت دارد. نامها نقش هر مرحله را توضیح میدهند؛ اتصال reader در مراحل پایانیِ آزمایش، یک نوشتن مشخص نیز انجام میدهد.
حالت journal_mode=WAL درخواست و پاسخ آن صریحاً بررسی میشود؛ اگر فعال نشود، آزمایش موفق گزارش نمیشود. read_uncommitted برای هر دو خاموش است. طبق مستندات جداسازی SQLite، اتصالهای جدا در این قرارداد، تغییر ثبتنشدهٔ اتصال دیگر را نمیبینند؛ اما همان اتصال نویسنده تغییرهای خودش را پیش از ثبت میبیند. این دو گزاره را نباید با هم اشتباه گرفت.
واحد ستون voltage_mv میلیولت و ترتیب خروجی، شناسهٔ صعودی است. این جدول، اعتبار فیزیکی حسگر یا نوعسنجی ورودی خارجی را آزمایش نمیکند. همچنین :memory: مجزا برای هر اتصال، مسئلهٔ ما را نمیسازد؛ هر دو باید واقعاً به همان فایل تازه دسترسی داشته باشند. مسیرهای نهایی پیش از ساخت و پاکسازی کنترل میشوند تا پایگاه دادهٔ موجود وارد آزمایش نشود.
۲. BEGIN DEFERRED هنوز نخستین مشاهده نیست

reader دستور BEGIN DEFERRED میدهد، ولی هنوز SELECT نکرده است. writer مقدار نمونهٔ یک را به ۱۱۰۰ تغییر میدهد و COMMIT میکند؛ سپس نخستین خواندن reader، ۱۱۰۰ و ۲۰۰۰ را میبیند. پس نباید وضعیت پیش از BEGIN را بدون نخستین دسترسی، تصویر منجمدِ خواننده فرض کنیم. بخش DEFERRED در مستندات تراکنش همین تأخیر تا نخستین دسترسی را توضیح میدهد.
پرچم in_transaction بعد از BEGIN میتواند درست باشد؛ این پرچم، زمان تشکیل دیدِ خواندن را بهتنهایی ثابت نمیکند. نخستین SELECT در این مسیر، تراکنش خواندن را آغاز میکند. تمام ردیفهای هر SELECT خوانده و cursor آن صریح بسته میشود. بنابراین رفتارِ نیمهخواندهماندن یک دستور و تغییر همان اتصال در میانهٔ پیمایش، موضوع این آزمون نیست.
اکنون writer در تراکنش خودش مقدار نمونهٔ یک را ۱۲۰۰ میکند، نمونهٔ سه با ۳۰۰۰ را میافزاید و نمونهٔ دو را حذف میکند. writer همین دادهٔ تازه را میبیند؛ reader هنوز ۱۱۰۰ و ۲۰۰۰ را میخواند. این مشاهدهٔ خواننده پیش از COMMIT، شاهد نبود خواندنِ تغییر ثبتنشده در تنظیمات همین آزمایش است؛ نتیجه را به همهٔ ترکیبهای cache و read_uncommitted تعمیم نمیدهیم.
۳. ثبت نویسنده، دید خواننده را خودکار عوض نمیکند

پس از COMMIT نویسنده، writer نمونههای یک/سه با ۱۲۰۰/۳۰۰۰ را دارد، ولی reader در تراکنش خواندنِ بازش همان یک/دو با ۱۱۰۰/۲۰۰۰ را نگه میدارد. توضیح end mark خواننده در WAL علت این دیدِ ثابت در طول تراکنش را شرح میدهد. اینجا دیدِ تراکنشی از همان پایگاه داده داریم؛ فایل پشتیبان یا مجموعهٔ کپیشدهٔ صفحهبندی ایجاد نکردهایم.
"""Original bounded two-connection WAL read-snapshot experiment on an owned local fixture."""
import json
import platform
import sqlite3
import tempfile
from pathlib import Path
def rows(con):
cursor=con.execute('SELECT id,voltage_mv FROM sample ORDER BY id')
try:
return cursor.fetchall()
finally:
cursor.close()
def error_code(function,*args):
try:
function(*args)
except sqlite3.OperationalError as error:
return error.sqlite_errorcode
return None
def committed_reference(initial,operations):
# Independent dictionary state update, not a second SQL query.
state=dict(initial)
for operation,key,value in operations:
if operation=='delete':
del state[key]
else:
state[key]=value
return sorted(state.items())
def run_example():
fixture_parent=Path(__file__).resolve().parent
run_dir=Path(tempfile.mkdtemp(prefix='sqlite-read-snapshot-',dir=fixture_parent)).resolve()
if run_dir.parent!=fixture_parent:
raise RuntimeError('fixture scope mismatch')
database=(run_dir/'fixture.sqlite').resolve()
if database.parent!=run_dir or database.exists():
raise RuntimeError('new owned fixture required')
reader=writer=None
cleanup=False
try:
uri=database.as_uri()+'?cache=private'
writer=sqlite3.connect(uri,uri=True,autocommit=True,timeout=0)
wal=writer.execute('PRAGMA journal_mode=WAL').fetchone()
if wal!=('wal',):
raise RuntimeError('WAL unavailable; do not claim snapshot checks completed')
reader=sqlite3.connect(uri,uri=True,autocommit=True,timeout=0)
for con in (reader,writer):
con.execute('PRAGMA read_uncommitted=OFF')
writer.execute('CREATE TABLE sample(id INTEGER PRIMARY KEY,voltage_mv INTEGER NOT NULL)')
writer.executemany('INSERT INTO sample VALUES(?,?)',[(1,1000),(2,2000)])
reader.execute('BEGIN DEFERRED')
began_active=reader.in_transaction
writer.execute('BEGIN IMMEDIATE')
writer.execute('UPDATE sample SET voltage_mv=1100 WHERE id=1')
writer.execute('COMMIT')
first=rows(reader) # First database read establishes this read snapshot.
writer.execute('BEGIN IMMEDIATE')
writer.execute('UPDATE sample SET voltage_mv=1200 WHERE id=1')
writer.execute('INSERT INTO sample VALUES(?,?)',(3,3000))
writer.execute('DELETE FROM sample WHERE id=2')
writer_uncommitted=rows(writer)
before_commit=rows(reader)
writer.execute('COMMIT')
after_commit=rows(reader)
writer_committed=rows(writer)
still_reading=reader.in_transaction
reader.execute('COMMIT')
ended_after_commit=not reader.in_transaction
new_read=rows(reader)
autostatement_ended=not reader.in_transaction
writer.execute('UPDATE sample SET voltage_mv=1300 WHERE id=1')
next_autoread=rows(reader)
reader.execute('BEGIN')
before_rollback_writer=rows(reader)
writer.execute('BEGIN IMMEDIATE')
writer.execute('UPDATE sample SET voltage_mv=1400 WHERE id=1')
writer.execute('ROLLBACK')
after_rollback_writer=rows(reader)
reader.execute('ROLLBACK')
reader.execute('BEGIN')
old_for_upgrade=rows(reader)
writer.execute('UPDATE sample SET voltage_mv=1500 WHERE id=1')
stale_code=error_code(reader.execute,'UPDATE sample SET voltage_mv=voltage_mv+1 WHERE id=1')
stale_still_active=reader.in_transaction
after_failed_upgrade=rows(reader)
reader.execute('ROLLBACK')
after_reader_rollback=rows(reader)
reader.execute('BEGIN IMMEDIATE')
reader.execute('UPDATE sample SET voltage_mv=1600 WHERE id=1')
reader.execute('COMMIT')
fresh_write=rows(writer)
reader.execute('BEGIN IMMEDIATE')
competing_code=error_code(writer.execute,'BEGIN IMMEDIATE')
reader.execute('ROLLBACK')
writer.execute('BEGIN IMMEDIATE')
released_writer_active=writer.in_transaction
writer.execute('ROLLBACK')
expected_first=[(1,1100),(2,2000)]
expected_changed=committed_reference([(1,1000),(2,2000)], [('update',1,1200),('insert',3,3000),('delete',2,None)])
checks={
'two_distinct_connections_reference_only_owned_same_local_file': reader is not writer
and all(Path(c.execute('PRAGMA database_list').fetchone()[2]).resolve()==database for c in (reader,writer)),
'wal_mode_explicitly_enabled_and_verified_on_both_connections': wal==('wal',) and reader.execute('PRAGMA journal_mode').fetchone()==('wal',),
'private_cache_uri_and_read_uncommitted_off_are_explicit_contract': '?cache=private'in uri
and all(c.execute('PRAGMA read_uncommitted').fetchone()==(0,)for c in (reader,writer)),
'begin_deferred_before_first_read_does_not_freeze_earlier_values': began_active and first==expected_first,
'writer_observes_own_uncommitted_changes_in_its_connection': writer_uncommitted==expected_changed,
'reader_does_not_observe_uncommitted_writer_changes': before_commit==expected_first,
'reader_keeps_prior_voltage_after_other_connection_commits': after_commit[0]==(1,1100) and writer_committed[0]==(1,1200),
'reader_keeps_prior_membership_across_committed_insert_and_delete': [r[0]for r in after_commit]==[1,2] and [r[0]for r in writer_committed]==[1,3],
'reader_transaction_stays_active_until_explicit_end': still_reading and ended_after_commit,
'reader_commit_then_new_read_observes_current_committed_state': new_read==expected_changed,
'fully_fetched_autocommit_reads_do_not_hold_same_snapshot_between_statements': autostatement_ended and next_autoread==[(1,1300),(3,3000)],
'writer_rollback_does_not_publish_its_transient_voltage': before_rollback_writer==after_rollback_writer==[(1,1300),(3,3000)],
'stale_read_to_write_upgrade_returns_busy_snapshot_without_silent_retry': stale_code==sqlite3.SQLITE_BUSY_SNAPSHOT
and stale_still_active and old_for_upgrade==after_failed_upgrade==[(1,1300),(3,3000)],
'reader_rollback_ends_old_snapshot_and_next_read_sees_later_commit': after_reader_rollback==[(1,1500),(3,3000)],
'new_immediate_write_after_ending_old_snapshot_can_commit_known_change': fresh_write==[(1,1600),(3,3000)],
'immediate_writer_reservation_blocks_second_writer_until_released': competing_code==sqlite3.SQLITE_BUSY and released_writer_active,
'independent_dictionary_oracle_matches_committed_state_not_read_snapshot': expected_changed==[(1,1200),(3,3000)] and writer_committed==expected_changed and after_commit!=expected_changed,
}
result={'runtime':{'python':platform.python_version(),'sqlite_runtime':sqlite3.sqlite_version,'journal_mode':'wal','busy_timeout_seconds':0},
'contract':{'data':'synthetic integer millivolts, ordered unique sample ids','connections':'two private-cache connections to one new local temporary file',
'execution':'explicit sequential interleaving, no threads/sleeps/timers/network; SQL BEGIN/COMMIT/ROLLBACK with Python autocommit=True',
'snapshot':'read view starts at first read after BEGIN DEFERRED, ends at explicit COMMIT or ROLLBACK'},
'metrics':{'reader_first_read':first,'writer_own_uncommitted':writer_uncommitted,'reader_before_writer_commit':before_commit,
'reader_after_writer_commit':after_commit,'writer_committed_rows':writer_committed,'reader_after_commit_new_read':new_read,
'reader_next_autocommit_read':next_autoread,'stale_upgrade_sqlite_code':stale_code,'reader_after_failed_upgrade':after_failed_upgrade,
'reader_after_rollback_new_read':after_reader_rollback,'new_immediate_write_committed':fresh_write,'competing_immediate_sqlite_code':competing_code},
'checks':checks,
'limitations':['Bounded sequential two-connection fixture only; no concurrency capacity, timing, server workload, network filesystem, crash durability or backup test.',
'WAL verified locally with private cache and read_uncommitted off; different journal/cache settings need separate contracts.',
'A read snapshot is a transaction-scoped view of the same database, not a persistent backup or a fixed-copy pagination dataset.',
'BUSY_SNAPSHOT is observed with this SQLite runtime; no blind or automatic retry of unknown production writes is implemented.']}
finally:
for con in (reader,writer):
if con is not None:
if con.in_transaction:con.execute('ROLLBACK')
con.close()
if run_dir.parent!=fixture_parent or database.parent!=run_dir:
raise RuntimeError('refuse cleanup outside owned fixture')
for name in ('fixture.sqlite','fixture.sqlite-wal','fixture.sqlite-shm'):
target=(run_dir/name).resolve()
if target.parent!=run_dir:raise RuntimeError('cleanup target mismatch')
if target.exists():target.unlink()
run_dir.rmdir() # No recursive deletion; unexpected files cause failure.
cleanup=not run_dir.exists()
result['checks']['owned_fixture_paths_verified_and_known_files_cleaned_nonrecursively']=cleanup
assert len(result['checks'])==18 and all(type(v)is bool and v for v in result['checks'].values())
return result
if __name__=='__main__':
print(json.dumps(run_example(),ensure_ascii=False,indent=2))
reader با COMMIT، تراکنش خواندن را تمام میکند؛ خواندن بعدی دادهٔ ثبتشدهٔ تازه را میبیند. در مرحلهٔ دیگر، ROLLBACK خواننده نیز دید قبلی را پایان میدهد. ROLLBACK یک خواننده، COMMIT اتصال نویسنده را باطل نمیکند. بیرون BEGIN صریح، دو SELECT کامل و بستهشده نیز الزاماً یک دید مشترک ندارند؛ در کد، تغییر بعدی نویسنده به ۱۳۰۰ در خواندن تازه دیده میشود.
اتصالهای پایتون با autocommit=True ساخته شدهاند و کنترل با دستور SQL صریح است. در قرارداد sqlite3 پایتون، روشهای commit و rollback اتصال در این حالت اثری ندارند؛ آنها را جای دستورهای SQL برنامه ننشاندهایم. این انتخاب برای آشکارکردن مرزهاست، نه توصیهٔ عمومیِ یک تنظیم برای همهٔ برنامهها.
۴. دید قدیمی برای نوشتن و مرز شواهد

در سناریوی بعد، reader مقدار ۱۳۰۰ را در تراکنش میخواند و writer بعداً ۱۵۰۰ را ثبت میکند. تلاش reader برای UPDATE روی همان دید قدیمی، در اجرای ما کد SQLITE_BUSY_SNAPSHOT برابر ۵۱۷ میدهد. تراکنش خواندن هنوز فعال و مشاهدهاش قدیمی است. کد بدون retry پنهان، آن را ROLLBACK میکند؛ سپس تراکنش تازهای برای تغییر معلوم به ۱۶۰۰ آغاز میشود. این، الگوی retry کورِ نوشتنِ نامعلوم در سامانهٔ واقعی نیست.
{
"runtime": {
"python": "3.14.5",
"sqlite_runtime": "3.50.4",
"journal_mode": "wal",
"busy_timeout_seconds": 0
},
"contract": {
"data": "synthetic integer millivolts, ordered unique sample ids",
"connections": "two private-cache connections to one new local temporary file",
"execution": "explicit sequential interleaving, no threads/sleeps/timers/network; SQL BEGIN/COMMIT/ROLLBACK with Python autocommit=True",
"snapshot": "read view starts at first read after BEGIN DEFERRED, ends at explicit COMMIT or ROLLBACK"
},
"metrics": {
"reader_first_read": [
[
1,
1100
],
[
2,
2000
]
],
"writer_own_uncommitted": [
[
1,
1200
],
[
3,
3000
]
],
"reader_before_writer_commit": [
[
1,
1100
],
[
2,
2000
]
],
"reader_after_writer_commit": [
[
1,
1100
],
[
2,
2000
]
],
"writer_committed_rows": [
[
1,
1200
],
[
3,
3000
]
],
"reader_after_commit_new_read": [
[
1,
1200
],
[
3,
3000
]
],
"reader_next_autocommit_read": [
[
1,
1300
],
[
3,
3000
]
],
"stale_upgrade_sqlite_code": 517,
"reader_after_failed_upgrade": [
[
1,
1300
],
[
3,
3000
]
],
"reader_after_rollback_new_read": [
[
1,
1500
],
[
3,
3000
]
],
"new_immediate_write_committed": [
[
1,
1600
],
[
3,
3000
]
],
"competing_immediate_sqlite_code": 5
},
"checks": {
"two_distinct_connections_reference_only_owned_same_local_file": true,
"wal_mode_explicitly_enabled_and_verified_on_both_connections": true,
"private_cache_uri_and_read_uncommitted_off_are_explicit_contract": true,
"begin_deferred_before_first_read_does_not_freeze_earlier_values": true,
"writer_observes_own_uncommitted_changes_in_its_connection": true,
"reader_does_not_observe_uncommitted_writer_changes": true,
"reader_keeps_prior_voltage_after_other_connection_commits": true,
"reader_keeps_prior_membership_across_committed_insert_and_delete": true,
"reader_transaction_stays_active_until_explicit_end": true,
"reader_commit_then_new_read_observes_current_committed_state": true,
"fully_fetched_autocommit_reads_do_not_hold_same_snapshot_between_statements": true,
"writer_rollback_does_not_publish_its_transient_voltage": true,
"stale_read_to_write_upgrade_returns_busy_snapshot_without_silent_retry": true,
"reader_rollback_ends_old_snapshot_and_next_read_sees_later_commit": true,
"new_immediate_write_after_ending_old_snapshot_can_commit_known_change": true,
"immediate_writer_reservation_blocks_second_writer_until_released": true,
"independent_dictionary_oracle_matches_committed_state_not_read_snapshot": true,
"owned_fixture_paths_verified_and_known_files_cleaned_nonrecursively": true
},
"limitations": [
"Bounded sequential two-connection fixture only; no concurrency capacity, timing, server workload, network filesystem, crash durability or backup test.",
"WAL verified locally with private cache and read_uncommitted off; different journal/cache settings need separate contracts.",
"A read snapshot is a transaction-scoped view of the same database, not a persistent backup or a fixed-copy pagination dataset.",
"BUSY_SNAPSHOT is observed with this SQLite runtime; no blind or automatic retry of unknown production writes is implemented."
]
}
BEGIN IMMEDIATE از ابتدا جایگاه نویسنده را میگیرد؛ در مرحلهٔ محدود دیگری، آغاز نویسندهٔ دوم تا رهاشدن آن با SQLITE_BUSY برابر پنج رد میشود. timeout صفر است و رقابت با خواب یا حدس زمان ساخته نمیشود. این توالی، ظرفیت همزمانی یا زمان انتظار بار واقعی را اندازه نمیگیرد؛ فقط نتیجهٔ ترتیب مشخصی از عملیات دو اتصال را ثبت میکند.
هجده گروه بررسی با Python 3.14.5 و SQLite 3.50.4 اجرا شدهاند. مرجع مستقل، تغییرهای ثبتشده را روی دیکشنری پایتون اعمال میکند؛ پاسخ مورد انتظار از SELECT دوم تولید نمیشود. مقدار، عضویت ردیفها، مرز نخستین خواندن، پایان تراکنش، rollback نویسنده و کدهای خطا جدا بررسی شدهاند. پس از بستن اتصالها، فقط سه نام فایلِ شناختهشده در پوشهٔ تازه، با کنترل مسیر و بدون حذف بازگشتی پاک میشوند.
نسخهٔ SQLite فقط محیطِ ثبتشدهٔ این اجراست و توصیهٔ استقرار نیست. بخش یازدهٔ منبع WAL، خطای WAL-reset و نسخههای اصلاحشده را توضیح میدهد؛ نمونهٔ تکریسمانیِ ما نوشتن و checkpoint هملحظه و اصلاح آن خطا را نمیآزماید. دوام در قطع برق، فایل شبکه و رفتار فشار تولید نیز خارج دامنهاند. منابع در ۲۵ مهر ۱۴۰۵ خوانده شدند؛ نسخهٔ مستندات پایتون 3.14.8 جدا ثبت است.
۵. چهار پرسش با پاسخ کامل

پرسش اول: چرا نخستین read مقدار ۱۱۰۰ را میبیند؟ پاسخ: writer پیش از نخستین SELECT آن را ثبت کرده است. BEGIN DEFERRED بهتنهایی مشاهدهٔ قبلی را منجمد نکرده بود؛ دید خواندن با دسترسی آغاز شد.
پرسش دوم: COMMIT نویسنده، reader باز را تازه میکند؟ پاسخ: در این WAL خیر؛ reader همان دید قبلی را نگه میدارد. باید تراکنش خواندن تمام و مشاهدهٔ تازه آغاز شود؛ COMMIT و ROLLBACK خواننده هر دو میتوانند این مرز را پایان دهند.
پرسش سوم: ROLLBACK خواننده تغییر ثبتشدهٔ writer را حذف میکند؟ پاسخ: خیر؛ فقط تراکنش خودِ خواننده پایان مییابد. تغییر مستقلِ ثبتشدهٔ نویسنده باقی است و خواندن بعدی میتواند آن را ببیند.
پرسش چهارم: snapshot این درس همان کپی ثابت است؟ پاسخ: خیر؛ نسخهٔ کپیشدهٔ صفحهبندی مستقل نگهداری میشود؛ اینجا دیدِ محدود به عمر تراکنش داریم. آزمون تراکنش دادهٔ حسگر نیز اتمیبودن ورود بسته را دنبال میکند؛ هیچیک جای دیگری نیست.
منابع و مطالعهٔ بیشتر
- SQLite؛ جداسازی اتصالها و snapshot خواننده در WAL
- SQLite؛ تراکنش DEFERRED، IMMEDIATE و پایان خواندن
- SQLite WAL؛ مرز خواننده، checkpoint و محدودیت نسخه
- Python 3.14؛ sqlite3، autocommit، in_transaction و کد خطا
بررسی منابع: ۲۵ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

