Ачааллж байна...
Өмнөх хоёр хичээлд бид transaction-ийг нэг холболтоор, нэг psql цонхоор судалсан. Гэвч Бүлэг 10-т server өгөгдлийн сангийн гол давуу талыг "олон холболт нэгэн зэрэг" гэж сурсан: гурван салбарын номын санч, вэб програмын олон хуулбар, хэдэн зуун хэрэглэгч — бүгд нэг server-тэй зэрэг ярьдаг. Тэгвэл хоёр transaction нэг мөрийг нэгэн зэрэг өөрчлөх гэвэл юу болох вэ? Нэг нь нөгөөгийнхөө ажлыг дарж бичих үү? Хоёулаа алдаа авах уу?
Энэ хичээлд бид хоёр psql цонх нээж, тэднийг хоёр өөр хэрэглэгч гэж үзэн, санаатайгаар мөргөлдүүлнэ. Эхлээд Бүлэг 12-ын эхний хичээлд амласан тусгаарлагдмал байдлыг өөрийн нүдээр харна: нэг цонхны дуусаагүй өөрчлөлт нөгөөд харагдахгүй. Дараа нь түгжээ (lock) — PostgreSQL мөргөлдөөнийг хэрхэн шийддэгийг: хоёр дахь transaction эхнийхээ дуусахыг хүлээдэг.
Түгжээ бол хамгаалалт, гэвч үнэтэй. Удаан нээлттэй transaction бусдыг удаан хүлээлгэдэг бөгөөд энэ нь вэб програмын "гэнэт удааширсан" асуудлын түгээмэл шалтгаан. Бид ийм нөхцлийг үүсгэж, буруутныг server-ээс олж, хүлээхийг хязгаарлаж сурна. Хичээлийн төгсгөлд хоёр transaction бие биеэ мөнхөд хүлээх deadlock-ийг товч харж, түгжрэлтэй холбоотой бүх асуудлыг шийддэг нэг практик дүрмээр дуусгана: transaction-ийг богино байлга.
Архивын албанд нэг хавтсыг хоёр ажилтан зэрэг засах гэж байна гэж бодъё. Хэрэв хоёулаа хавтсыг зэрэг аваад, тус бүр өөрийн хуулбар дээрээ засаад, буцааж тавибал — сүүлд тавьсан нь эхнийхийнхээ засварыг дарж бичнэ. Эхний ажилтны ажил ул мөргүй алга болно. Тиймээс архивын журам энгийн: хавтсыг засахаар авсан ажилтан түүн дээр "засварт байна" гэсэн шошго наана. Хоёр дахь ажилтан ирээд шошгыг хараад хүлээнэ. Эхнийх нь засвараа дуусгаж, шошгыг авмагц хоёр дахь нь хавтсыг — эхнийхийн засвартай нь — авч, өөрийн засвараа нэмнэ. Хоёр засвар хоёулаа хадгалагдана.
Энэ шошго бол түгжээ. Журмын хоёр чухал нарийн зүйл бий. Нэгдүгээрт, шошго зөвхөн тухайн хавтсанд наагдана — бусад хавтсанд хүссэн ажилтан чөлөөтэй ажиллана. Хоёрдугаарт, хавтсыг зөвхөн унших гэсэн хүнд шошго саад болохгүй — тэр засвараас өмнөх хувилбарыг уншина. Харин журмын сул тал: хэрэв эхний ажилтан шошго наагаад өдрийн хоолонд явбал хоёр дахь нь бүтэн цаг хүлээнэ.
Өгөгдлөө эхлэл байдалд буцаана: psql -U postgres -d bank -f bank_reset.sql. Дараа нь хоёр PowerShell цонх нээж, хажуу хажууд нь байрлуулна. Цонх бүрт кирилл тохиргоог хийгээд (chcp 65001, $env:PGCLIENTENCODING = "UTF8") psql -U postgres -d bank гэж холбогдоно. Энэ хичээлд зүүн цонхыг Цонх А, баруун цонхыг Цонх Б гэж нэрлэнэ. Тэд хоёр өөр холболт — хоёр өөр хэрэглэгч, Бүлэг 11-ийн pg_stat_activity-д хоёр тусдаа процесс. Тушаал бүрийн өмнө аль цонхонд бичихийг заана — дарааллыг яг дагаарай, учир нь энэ хичээлд хугацаа чухал.
Цонх А:
bank=# BEGIN;
BEGIN
bank=*# UPDATE accounts SET balance = balance - 30000 WHERE id = 1;
UPDATE 1
bank=*# SELECT owner, balance FROM accounts WHERE id = 1;
owner | balance
-------+---------
Бат | 20000
(1 row)Цонх А transaction эхлүүлж, Батын данснаас 30,000 хасаад, өөрийн өөрчлөлтийг харж байна: 20,000. Энэ нь өмнөх хичээлийн нээлттэй хавтас.
Цонх Б:
bank=# SELECT owner, balance FROM accounts WHERE id = 1;
owner | balance
-------+---------
Бат | 50000
(1 row)Цонх Б 50,000 харж байна. Ижил мөр, ижил агшин — хоёр өөр хариу. Энэ query юу гэж хэлж байна вэ гэвэл: "Батын данс ба үлдэгдлийг харуул" — гэвч Цонх Б-ийн хувьд Цонх А-ийн өөрчлөлт хараахан байхгүй, учир нь тэр хадгалагдаагүй. Бүлэг 12-ын эхний хичээлийн тусгаарлагдмал байдлын баталгаа ажиллаж байна: хэрэв энэ банкны шилжүүлэг байсан бол Цонх Б-ийн хэрэглэгч "мөнгө хасагдсан, гэвч хаашаа ч ороогүй" гэсэн хагас байдлыг хэзээ ч харахгүй.
Цонх А:
bank=*# COMMIT;
COMMITЦонх Б:
bank=# SELECT owner, balance FROM accounts WHERE id = 1;
owner | balance
-------+---------
Бат | 20000
(1 row)COMMIT-ийн дараа Цонх Б шинэ утгыг харж байна. Өөрчлөлт "тамгалагдсан" мөчөөс эхлэн бүх хүнд харагдана. PostgreSQL-ийн анхдагч тусгаарлагдмал байдлын түвшинг READ COMMITTED — "хадгалагдсаныг л унш" — гэдэг бөгөөд нэр нь яг энэ зан чанарыг тайлбарлаж байна. Илүү хатуу түвшингүүд бас бий, гэхдээ ихэнх програмд анхдагч түвшин хангалттай.
Өгөгдлөө сэргээе: аль нэг цонхноос \q, psql -U postgres -d bank -f bank_reset.sql, дахин холбогдох.
Жишээ 1: мөргөлдөөн ба түгжээ
Одоо хоёр transaction нэг мөрийг зэрэг өөрчлөх гэж оролдъё. Цонх А Батын данснаас 10,000 хасаж, Цонх Б Батын дансанд 5,000 нэмнэ. Эхлэл: Бат 50,000.
Цонх А:
bank=# BEGIN;
BEGIN
bank=*# UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
UPDATE 1Цонх Б:
bank=# UPDATE accounts SET balance = balance + 5000 WHERE id = 1;Цонх Б-д юу ч болохгүй. Хариу ч, шинэ урилга ч гарахгүй — курсор анивчсаар хүлээнэ. psql гацаагүй: Цонх Б-ийн UPDATE Батын мөр дээр Цонх А-ийн тавьсан түгжээг хараад хүлээж байна. Зүйрлэлийн "засварт байна" шошго. Цонх А transaction-аа дуусгах хүртэл Цонх Б хүлээнэ — секунд, минут, цаг.
Цонх А:
bank=*# COMMIT;
COMMITЦонх А COMMIT хийх агшинд Цонх Б-д нэгэн зэрэг хариу гарна:
UPDATE 1
bank=#Одоо аль ч цонхноос шалгая:
sql
SELECT owner, balance FROM accounts WHERE id = 1; owner | balance
-------+---------
Бат | 4500050,000 − 10,000 + 5,000 = 45,000. Хоёр өөрчлөлт хоёулаа хадгалагдсан. Энэ нь санамсаргүй биш. Цонх Б хүлээж байх үед түгжээ сулармагц тэр Батын мөрийн хамгийн сүүлийн, хадгалагдсан утгыг (40,000) дахин уншиж, түүн дээр 5,000 нэмсэн. Хэрэв түгжээ байгаагүй бол Цонх Б 50,000 дээр 5,000 нэмж 55,000 гэж бичих, эсвэл Цонх А-ийн 40,000-ийг дарах байсан — зүйрлэлийн "сүүлд тавьсан нь эхнийхийг дарж бичнэ." Ийм алдааг "алдагдсан шинэчлэл" (lost update) гэдэг бөгөөд түгжээ түүнээс хамгаалсан. Мөн balance = balance + 5000 гэж хуучин утгаас тооцоолсон нь чухал: өгөгдлийн сан шинэ утгыг түгжээ сулрах мөчид тооцоолдог. Хэрэв програм эхлээд үлдэгдлийг уншиж, Python-д 5,000 нэмээд, тогтмол тоог бичсэн бол (SET balance = 55000) энэ хамгаалалт ажиллахгүй байх байсан.
Жишээ 2: түгжээ хаана хамаарах вэ
Түгжээ бүх хүснэгтийг биш, зөвхөн өөрчилсөн мөрүүдийг түгждэг. Өгөгдлөө сэргээгээд туршъя.
Цонх А:
bank=# BEGIN;
BEGIN
bank=*# UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
UPDATE 1Цонх Б — өөр мөр:
bank=# UPDATE accounts SET balance = balance + 5000 WHERE id = 2;
UPDATE 1Тэр даруй хариу гарсан. Сараагийн мөр түгжигдээгүй — Цонх А зөвхөн Батын мөрийг түгжсэн. Энэ бол мөрийн түвшний түгжээ: мянган дансны банкинд нэг шилжүүлэг зөвхөн хоёр мөрийг түгжиж, бусад 998 дансанд хүмүүс зэрэг ажиллана.
Цонх Б — уншиж үзье:
bank=# SELECT owner, balance FROM accounts WHERE id = 1;
owner | balance
-------+---------
Бат | 50000Уншилт ч тэр даруй хариу өгсөн — түгжигдсэн мөрийг ч. Зүйрлэлийн "унших гэсэн хүнд шошго саад болохгүй": Цонх Б засвараас өмнөх, хадгалагдсан хувилбарыг уншина. PostgreSQL-д уншигчид бичигчдийг, бичигчид уншигчдыг хүлээлгэдэггүй — зөвхөн ижил мөрийг өөрчлөх гэсэн хоёр бичигч л бие биеэ хүлээлгэнэ. Энэ нь олон хэрэглэгчтэй системийг хурдан байлгах гол шалтгааны нэг.
Цонх А: ROLLBACK; — туршилтыг цуцалъя. Цонх Б-ийн Сараагийн өөрчлөлт autocommit-оор аль хэдийн хадгалагдсан тул сэргээх хэрэгтэй: \q, bank_reset.sql, дахин холбогдох.
Жишээ 3: мартагдсан transaction ба хүлээлтийн хязгаар
Одоо зүйрлэлийн "шошго наагаад өдрийн хоолонд явсан ажилтан"-ыг үүсгэе.
Цонх А:
bank=# BEGIN;
BEGIN
bank=*# UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
UPDATE 1Цонх А-д юу ч бүү бич — тэр хэрэглэгч "кофе уухаар явлаа."
Цонх Б — энэ удаад мөнхөд хүлээхгүйн тулд хүлээлтийн хязгаар тавья:
bank=# SET lock_timeout = '3s';
SET
bank=# UPDATE accounts SET balance = balance + 5000 WHERE id = 1;Гурван секундын дараа:
ERROR: canceling statement due to lock timeout
CONTEXT: while updating tuple (0,4) in relation "accounts"Энэ мессеж юу гэж хэлж байна вэ гэвэл: "түгжээ хүлээх хугацаа дууссан тул тушаалыг цуцаллаа; accounts хүснэгтийн нэг мөрийг шинэчлэх үед." SET lock_timeout = '3s' нь энэ холболтод "түгжээг 3 секундээс илүү бүү хүлээ" гэсэн тохиргоо. Анхдагчаар энэ хязгаар байхгүй — Цонх Б хэдэн цаг ч хүлээх байсан. Вэб програмд хязгааргүй хүлээлт нь хүсэлт гацаж, Бүлэг 11-ийн pool дүүрч, бүх сайт зогсохтой адил. CONTEXT мөрийн (0,4) нь мөрийн дотоод байршил — үл тоомсорлож болно.
Одоо буруутныг олъё. Ийм нөхцөлд хамгийн хэрэгтэй асуулт: "хэн түгжээ барьж байна?" Бүлэг 11-ийн pg_stat_activity хариулна. Цонх Б:
sql
SELECT pid, state, query
FROM pg_stat_activity
WHERE datname = 'bank'; pid | state | query
-------+---------------------+-------------------------------------------------------------
18244 | idle in transaction | UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
18260 | active | SELECT pid, state, query FROM pg_stat_activity WHERE ...
(2 rows)Энэ query юу гэж хэлж байна вэ гэвэл: “bank өгөгдлийн сантай холбогдсон процессууд, тэдгээрийн төлөв, сүүлд ажиллуулсан query-г харуул.” Эхний мөр бол Цонх А: төлөв нь idle in transaction — “transaction дотор, гэхдээ юу ч хийхгүй байна.” Энэ бол мэргэжлийн хүмүүсийн хамгийн сонор сэрэмжтэй хайдаг төлөв: transaction нээлттэй, түгжээ барьсан, гэвч ажил хийгдэхгүй — хэн нэгэн эсвэл ямар нэг код COMMIT-оо мартсан. Сүүлийн query нь түгжээ үүсгэсэн UPDATE. Бодит server-т администратор энэ мөрийг олж, тэр програмыг засах эсвэл тэр холболтыг таслах шийдвэр гаргана.
Цонх А: ROLLBACK; — "кофенаас буцаж ирээд" цуцаллаа. Энэ мөчөөс Цонх Б-ийн UPDATE саадгүй ажиллана.
Жишээ 4: deadlock — хоёр шилжүүлэг бие биеэ хүлээх
Хамгийн сүүлийн нөхцөл. Өгөгдлөө сэргээгээд, Бат Сараад, Сараа Батад зэрэг мөнгө шилжүүлж байна гэж бодъё. Шилжүүлэг бүр өмнөх хичээлийн шиг эхлээд илгээгчийн, дараа нь хүлээн авагчийн дансыг өөрчилнө.
Цонх А (Бат → Сараа, эхний алхам):
bank=# BEGIN;
bank=*# UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE 1Цонх Б (Сараа → Бат, эхний алхам):
bank=# BEGIN;
bank=*# UPDATE accounts SET balance = balance - 1000 WHERE id = 2;
UPDATE 1Одоо Цонх А Батын мөрийг, Цонх Б Сараагийн мөрийг түгжсэн. Цонх А (хоёр дахь алхам):
bank=*# UPDATE accounts SET balance = balance + 1000 WHERE id = 2;Цонх А хүлээж эхэлнэ — Сараагийн мөрийг Цонх Б барьж байна. Цонх Б (хоёр дахь алхам):
bank=*# UPDATE accounts SET balance = balance + 1000 WHERE id = 1;Одоо Цонх Б Батын мөрийг хүлээнэ — түүнийг Цонх А барьж байна. Хоёр transaction тус бүр нөгөөгийнхөө барьсан түгжээг хүлээж байна: А нь Б-г хүлээж, Б нь А-г хүлээж — тэд хэзээ ч дуусахгүй. Үүнийг deadlock гэдэг. PostgreSQL хэдхэн хормын дотор (анхдагчаар нэг секундын дараа) энэ тойргийг илрүүлж, нэг transaction-ийг золиосолно. Цонх Б-д:
ERROR: deadlock detected
DETAIL: Process 18260 waits for ShareLock on transaction 891; blocked by process 18244.
Process 18244 waits for ShareLock on transaction 892; blocked by process 18260.
HINT: See server log for query details.Энэ мессеж юу гэж хэлж байна вэ гэвэл: “deadlock илэрлээ; 18260 процесс 891 transaction-ийг хүлээж, 18244-д хаагдсан; 18244 процесс 892-ыг хүлээж, 18260-д хаагдсан” — тойрог яг дүрслэгдсэн. Цонх Б-ийн transaction алдаатай төлөвт орсон (урилга !), Цонх А-ийн хүлээлт тэр даруй дуусаж UPDATE 1 гарна. Цонх Б-д ROLLBACK;, Цонх А-д COMMIT; бичнэ — нэг шилжүүлэг хийгдэж, нөгөө нь бүхэлдээ цуцлагдсан, мөнгө алга болоогүй.
Шударгаар хэлэхэд deadlock бол өгөгдлийн сангийн алдаа биш, харин хоёр transaction түгжээг эсрэг дарааллаар авсны үр дүн. PostgreSQL түүнийг илрүүлж, бүхэл байдлаа хадгалж шийддэг ч золиос болсон transaction-ийг програм дахин оролдох ёстой. Урьдчилан сэргийлэх энгийн арга бий: бүх transaction мөрүүдийг ижил дарааллаар түгжих. Жишээ нь шилжүүлэг бүр эхлээд бага дугаартай дансыг, дараа нь их дугаартайг өөрчилдөг бол Бат→Сараа ч, Сараа→Бат ч хоёулаа эхлээд Батын мөрийг түгжинэ — хоёр дахь нь эхнийхийнхээ дуусахыг энгийнээр хүлээж, тойрог үүсэхгүй. Ихэнх програмд deadlock ховор тохиолддог бөгөөд түүнийг бүрмөсөн арилгах гэхээсээ илүү ховор болгож, тохиолдвол дахин оролдох нь практик хандлага юм.
Энэ хичээлийн бүх асуудал — удаан хүлээлт, idle in transaction, deadlock — нэг нийтлэг шалтгаантай: түгжээ шаардлагатайгаас удаан барьсан. Тиймээс бүгдийг нэг дүрэм шийддэг: transaction-ийг аль болох богино байлга. Үүний практик утга:
Transaction дотор хэрэглэгчийн оролт хүлээж болохгүй. Python-д with engine.begin() блок дотор input(), хэрэглэгчийн баталгаажуулалтын дэлгэц бүү хий — шийдвэрээ transaction-ий өмнө гарга.
Transaction дотор удаан гадаад ажил бүү хий: имэйл илгээх, өөр вэб сайт руу хүсэлт, том файл унших. Тэдгээрийг COMMIT-ийн өмнө эсвэл дараа хий.
Зөвхөн хамт амжилттай эсвэл хамт бүтэлгүйтэх ёстой тушаалуудыг нэг transaction-д багцал — өмнөх хичээлийн шилжүүлгийн хоёр UPDATE. Бусдыг тусад нь.
psql-д BEGIN хийсэн бол цонхоо орхихоосоо өмнө COMMIT эсвэл ROLLBACK хий. Урилгад * байвал та түгжээ барьж байж магадгүй.
Бодит програмд lock_timeout-ийг тохируулах нь хязгааргүй хүлээлтээс хамгаална.
FastAPI програмын хувьд Бүлэг 11-ийн "хүсэлт бүрт нэг session" загвар энэ дүрмийг байгалиараа дагадаг: transaction нь нэг хүсэлтийн хугацаагаар — ихэвчлэн секундын хэсэгт — хязгаарлагдана.
Хэрэв сонирхож байвал Жишээ 4-ийг "ижил дараалал" дүрмээр давтаж үзээрэй: хоёр цонх хоёулаа эхлээд id = 1, дараа нь id = 2 мөрийг өөрчилнө (Сараа → Бат шилжүүлгийн хувьд энэ нь эхлээд хүлээн авагчийг, дараа нь илгээгчийг өөрчлөх гэсэн үг). Deadlock гарах уу, эсвэл хоёр дахь цонх зүгээр хүлээх үү? Мөн Цонх А-д BEGIN хийж мөр түгжээд, psql цонхыг X товчоор шууд хааж, Цонх Б хүлээлтээс чөлөөлөгдөх эсэхийг хараарай — өмнөх хичээлийн "Туршаад үзээрэй" асуултын хариулт энд байна. Дуусаад bank_reset.sql-ийг ажиллуулна. Эдгээр нь зөвхөн санал бөгөөд дараагийн хичээлүүд тэдгээрийг хийсэн гэж тооцохгүй.
“psql гацлаа.” UPDATE эсвэл DELETE-ийн дараа хариу ирэхгүй бол psql ихэнхдээ гацаагүй, түгжээ хүлээж байна. Өөр цонхноос pg_stat_activity-д idle in transaction төлөвтэй процессыг хайж ол. Ctrl+C нь хүлээж буй тушаалыг цуцалдаг.
canceling statement due to lock timeout. lock_timeout тохиргоотой үед түгжээ хугацаандаа сулраагүй. Энэ нь өөр transaction хэт удаан нээлттэй байгааг илтгэнэ — хүлээлтийг уртасгах нь шийдэл биш, урт transaction-ийг олж богиносгох нь шийдэл.
deadlock detected. Хоёр transaction түгжээг эсрэг дарааллаар авсан. Золиос болсон transaction алдаатай төлөвт орсон тул ROLLBACK хийж, дахин оролдоно. Python-д ийм алдаа psycopg.errors.DeadlockDetected хэлбэрээр ирдэг бөгөөд бодит програмд түүнийг барьж аваад нэг хоёр удаа дахин оролдох нь түгээмэл. Ихэвчлэн тохиолдвол мөрүүдийг ижил дарааллаар түгжих.
Python-д уншаад, тооцоолоод, тогтмол утга бичих. Програм SELECT balance хийгээд, Python-д тооцоолоод, UPDATE ... SET balance = 55000 гэж бичвэл Жишээ 1-ийн хамгаалалт ажиллахгүй — хоёр зэрэг ажиллах хүсэлт ижил хуучин утгыг уншиж, нэг нь нөгөөгийнхөө ажлыг дарна (алдагдсан шинэчлэл). Аль болох SET balance = balance + :amount гэж өгөгдлийн санд тооцоол. Заавал эхлээд унших шаардлагатай бол PostgreSQL-ийн SELECT ... FOR UPDATE бичлэг уншихдаа л мөрийг түгждэг — энэ сургалтын хүрээнээс гадуур ч нэрийг нь мэдэж байх нь тустай.
Transaction дотор хүлээх. Хэрэглэгчийн оролт, удаан сүлжээний хүсэлт, time.sleep() transaction дотор байвал түгжээ тэр хугацаагаар барьж байдаг. Энэ нь хөгжүүлэгчийн компьютер дээр (ганц хэрэглэгч) огт мэдэгдэхгүй, харин олон хэрэглэгчтэй үед бүх програмыг удаашруулна.
Энэ хичээлд та хоёр psql цонхоор зэрэг ажиллаж буй transaction-уудын харилцааг харлаа. Тусгаарлагдмал байдлын ачаар нэг transaction-ий хадгалаагүй өөрчлөлт бусдад харагдахгүй — PostgreSQL-ийн анхдагч READ COMMITTED түвшинд хадгалагдсан нь л харагдана. Хоёр transaction нэг мөрийг өөрчлөх гэвэл хоёр дахь нь эхнийхийнхээ барьсан түгжээг хүлээж, сулармагц хамгийн сүүлийн утгаас тооцоолох тул хоёр өөрчлөлт хоёулаа хадгалагдана — алдагдсан шинэчлэлээс хамгаална. Түгжээ мөрийн түвшинд ажилладаг, уншигчдыг саатуулдаггүй. Удаан нээлттэй transaction (idle in transaction) бусдыг хүлээлгэдэг бөгөөд pg_stat_activity түүнийг олж, lock_timeout хүлээлтийг хязгаарладаг. Хоёр transaction түгжээг эсрэг дарааллаар авбал deadlock үүсч, PostgreSQL нэгийг нь цуцалдаг; ижил дарааллаар түгжих нь урьдчилан сэргийлнэ. Бүх асуудлын практик шийдэл: transaction-ийг богино байлга.
Ингээд Бүлэг 12 ба Түвшин 4 — PostgreSQL — бүрэн дууслаа. Та server өгөгдлийн сан суулгаж, Python-оос аюулгүй холбогдож, холболтыг зөв зохицуулж, олон алхамт өөрчлөлтийг transaction-оор хамгаалж чаддаг болсон. Түвшин 5 бол мэргэжлийн дадлын түвшин. Бүлэг 13 Бүлэг 4-ийн ALTER хичээлд тавьсан асуултад хариулна: хүснэгтийн бүтцийг цаг хугацааны явцад, кодтой уялдуулан, түүхтэйгээр хэрхэн өөрчлөх вэ? Хариулт нь migration — өгөгдлийн сангийн бүтцэд зориулсан Git.
Бүртгэлтэй болсноор энэ сургалтын бүх хичээлд хандах эрх авна.