Part 01 · Backend Mindset

Two Users, One Action

RaceConditionConcurrency

দুজন একই মুহূর্তে শেষ ticket টা কিনলে কী হয়, আর এটা কেন কঠিন।

⏱ 1-2 ঘণ্টা · Station 6 / 153

Seat একটা। Ticket পাঁচটা।

একটা concert এ একটা seat বাকি। একই সেকেন্ডের মধ্যে পাঁচজন Buy চাপলো। তোমার code সাবধানী: বেচার আগে check করে seat আছে কিনা।

পাঁচজনই confirmation email পেলো। অনুষ্ঠানের দিন পাঁচজনই হাজির।

কারো code crash করেনি। প্রতিটা request ঠিক তাই করেছে যা তাকে লিখে দেওয়া হয়েছিল। তবুও system একটা seat এর জন্য পাঁচটা ticket বেচে দিলো। এই lesson এ দেখবো এটা কীভাবে হয়, test এ কেন ধরা পড়ে না, আর engineer রা কয়েকটা উপায়ে এটা আটকায়।

সমস্যা: check করা আর কাজ করা দুটো আলাদা ধাপ

Buy এর handler টা দেখতে একদম ঠিকঠাক:

  1. কয়টা seat বাকি পড়ো।
  2. Check করো অন্তত 1টা আছে কিনা।
  3. নতুন সংখ্যা লেখো, আর ticket দাও।

একবারে একজন user থাকলে এটা ঠিক। একসাথে অনেক user থাকলে (lesson 1), আলাদা আলাদা request এর ধাপগুলো একটার ভেতরে আরেকটা ঢুকে যেতে পারে। Request A পড়লো, তারপর request B পড়লো, কেউ কিছু লেখার আগেই দুজনই দেখলো "1টা seat বাকি"।

মুহূর্তAlice এর requestBob এর requestStorage এ seat
t1পড়ে: 11
t2পড়ে: 11
t3check 1 ≥ 1: ঠিক1
t4check 1 ≥ 1: ঠিক1
t50 লেখে, ticket!0
t60 লেখে, ticket!0

দুটো ticket বিক্রি হলো, আর সংখ্যা বলছে 0, দেখে একদম স্বাভাবিক লাগে। এটাই race condition: একসাথে চলা কাজগুলোর timing এর ওপর ফলাফল নির্ভর করে। এই নির্দিষ্ট ধরনটা, পড়ো, তারপর ঠিক করো, তারপর লেখো, এর নাম check-then-act, আর backend code এ এটাই সবচেয়ে বেশি দেখা race।

দুজন মানুষ আর একটা whiteboard

একটা মেসের ফ্রিজের পাশে whiteboard এ লেখা: "দুধ: 1 বোতল"। দুজন একই সময়ে পাশ দিয়ে যাচ্ছিল, দুজনই "1" পড়লো, আর দুজনই ভাবলো: "দুধ আছে, চায়ের জন্য নিয়ে নিই"। দুজনই ফ্রিজের দিকে গেলো। একজন দুধ পেলো; আরেকজন দেখলো তাক খালি, আর board এ লেখা 0, তাই কেউ কোনো গোলমাল টেরই পেলো না।

পড়ার সময় board ভুল ছিল না। শুধু কাজ করতে করতে সেটা পুরনো হয়ে গিয়েছিল। সমাধান আরো মন দিয়ে পড়া না; সমাধান হলো "board পড়ে দুধ নেওয়া" কাজটা এমন বানানো যাতে একবারে একজনই করতে পারে, অথবা ফ্রিজের দিকে যাওয়ার আগেই বোতলে নিজের নাম লিখে দেওয়া।

read, check, write: the gap in the middleAliceBobseatsstarts at 1ticketticketread 1read 1both saw 1 seatboth wrote 0: two tickets, one seatwith a lock: Bob waits, reads 0, refusedread 0
Alice আর Bob দুজনই "1 seat" পড়ে, দুজনই 0 লেখে, দুজনই ticket পায়। তারপর একই দুজন, এবার lock সহ: Alice শেষ না করা পর্যন্ত Bob কে অপেক্ষা করতে হয়, সে 0 পড়ে, আর ফেরত যায়।

Analogy টা কোথায় খাটে না। মানুষের লাগে কয়েক সেকেন্ড; request গুলো microsecond এর মধ্যে একটার ওপর আরেকটা পড়ে, সেকেন্ডে হাজারবার। আর মানুষ অন্তত মাঝে মাঝে একে অপরকে খেয়াল করে। Request কখনো করে না: প্রত্যেকটা এমনভাবে চলে যেন সে একা, আর code দেখে বোঝার উপায় নেই যে আরেকটা request ঠিক একই লাইনগুলোর মাঝখানে আছে।

মূল জিনিসগুলো

  1. Concurrency। একই সময়ে দুই বা তার বেশি কাজ চলছে, thread, goroutine, async task বা আলাদা server এ। Backend এ এটা কেন লাগে, lesson 1 এ দেখেছি; এই lesson এ দেখবো এর দাম।
  2. Shared state। এমন কিছু যা একাধিক request পড়তে আর লিখতে পারে: seat এর সংখ্যা, balance, stock, username। Shared state না থাকলে race ও নেই।
  3. Race condition। ফলাফল timing এর ওপর নির্ভর করে। একই code, একই input, বেশিরভাগ সময় ঠিক, মাঝে মাঝে ভুল।
  4. Critical section। যে লাইনগুলো একটার ভেতরে আরেকটা ঢুকতে দেওয়া যাবে না: এখানে, সংখ্যা পড়া, check করা, লেখা। সমাধান সবসময় একটাই: critical section কে একটা অবিভাজ্য ধাপ (atomic) বানানো। সাধারণ উপায় তিনটা:
    • Lock (mutual exclusion)। Critical section এর ভেতরে একবারে একটাই request থাকতে পারে; বাকিরা অপেক্ষা করে। সহজ, কিন্তু শুধু একটা process এর ভেতরে কাজ করে, আর অপেক্ষায় সময় যায়।
    • Atomic conditional write। Check আর লেখা দুটোই storage কে দিয়ে একসাথে করাও: UPDATE seats SET left = left - 1 WHERE id = 7 AND left >= 1, তারপর দেখো কয়টা row বদলেছে। 1 মানে তুমি পেয়েছো, 0 মানে sold out। অনেক server থাকলেও এটা কাজ করে।
    • Uniqueness rule। Database কে দিয়ে duplicate আটকাও: (concert, seat) বা username এ একটা unique constraint। দুটো insert এর race হয়; একটা জেতে, আরেকটা error পায়।
  5. Test এ কেন ধরা পড়ে না। তোমার laptop এ একবারে একজন click করে, আর পড়া আর লেখার মাঝের ফাঁক কয়েক microsecond। Production এ হাজার request আর কয়েক millisecond এর database round trip মিলে ফাঁকটা এত বড় হয় যে প্রতিদিনই ওতে পা পড়ে।
আগে অনুমান করো

Node.js JavaScript চালায় একটা thread এ। একটা handler করে: let left = await getSeats(); if (left >= 1) await setSeats(left - 1)। দুটো request কি তবুও একই seat বেচে দিতে পারে?

ভেতরে কী হয়: দুটো request, একটা counter

একই দুজন ক্রেতা, প্রথমে কোনো সুরক্ষা ছাড়া, তারপর lock সহ।

ধাপে ধাপে দেখো

Check-then-act, lock ছাড়া আর lock সহ

AliceBobSeat countLock

ধাপ 1/6 Alice সংখ্যাটা পড়ে

Alice এর request storage কে জিজ্ঞেস করে কয়টা seat বাকি। উত্তর 1। তারপর পরের ধাপের জন্য অপেক্ষা করে, আসল code database এর সাথে কথা বলার সময় যেমন করে।

Alice saw
1
seats
1

মাথায় রাখার ছবি

প্রতিটা request কে ধাপের একটা আড়াআড়ি লাইন হিসেবে আঁকো, আর লাইনগুলো একটার ওপর আরেকটা রাখো, সবাই একই time এর দাগে। যেখানেই দুটো লাইনে একই জিনিসের একটা পড়া আর একটা লেখা আছে, আর সেগুলো একটা আরেকটার পাশ দিয়ে সরে যেতে পারে, সেখানেই race এর সম্ভাবনা।

তারপর যে code কিছু পড়ে আবার লিখে দেয়, তাকে একটা প্রশ্ন করো:

আমার পড়া আর লেখার মাঝে অন্য কেউ এটা বদলে দিলে কী হবে?

উত্তর যদি হয় "ভুল ফল", তাহলে পড়া-check-লেখা কে একটা ধাপ বানাতে হবে: একটা lock, একটা atomic conditional update, বা একটা unique constraint।

চেষ্টা করো: race আছে কি নেই?

প্রতিটা case চালিয়ে দেখো

এখানে কি race আছে?

likes = getLikes(post)
setLikes(post, likes + 1)
ভাঙে: Race। একসাথে দুটো like এলে দুটোই 10 পড়ে, দুটোই 11 লেখে; একটা like হারায়। Atomic increment (UPDATE posts SET likes = likes + 1) ব্যবহার করো, বা likes table এর row গোনো।

গোড়া থেকে code

একটা seat, পাঁচজন ক্রেতা, সবাই একই মুহূর্তে Buy চাপে। পড়া আর লেখার মাঝে 50 ms এর একটা বিরতি, database round trip এর জায়গায়।

  • check, then buy: পড়া, বিরতি, check, লেখা, আলাদা আলাদা ধাপে।
  • check and buy as one step: একই logic একটা lock এর ভেতরে, যাতে critical section এ একবারে একজনই থাকতে পারে।

Node.js এ thread একটাই, তাই "বিরতি" টা একটা await; একই race তৈরি হতে ওটুকুই যথেষ্ট।

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

const buyers = 5

// The gap between reading the seat count and writing it back, like a database round trip.
func gap() { time.Sleep(50 * time.Millisecond) }

// run lets every buyer try at the same moment and counts the results.
func run(buy func() bool) (sold, refused int) {
	var mu sync.Mutex
	var wg sync.WaitGroup
	for range buyers {
		wg.Add(1)
		go func() {
			defer wg.Done()
			ok := buy()
			mu.Lock()
			if ok {
				sold++
			} else {
				refused++
			}
			mu.Unlock()
		}()
	}
	wg.Wait()
	return sold, refused
}

func main() {
	fmt.Printf("1 seat left, %d people press Buy at the same moment\n", buyers)

	// Check, then buy: two separate steps with a gap between them.
	var seats atomic.Int64
	seats.Store(1)
	sold, refused := run(func() bool {
		left := seats.Load() // read
		gap()
		if left < 1 { // check
			return false
		}
		seats.Store(left - 1) // write
		return true
	})
	fmt.Printf("%-28s sold %d, refused %d, seats left %d\n", "check, then buy:", sold, refused, seats.Load())

	// Check and buy as one step: a lock lets only one buyer in at a time.
	var lock sync.Mutex
	left := 1
	sold, refused = run(func() bool {
		lock.Lock()
		defer lock.Unlock()
		gap()
		if left < 1 {
			return false
		}
		left--
		return true
	})
	fmt.Printf("%-28s sold %d, refused %d, seats left %d\n", "check and buy as one step:", sold, refused, left)
}

Output (পাঁচটা language এই একই):

1 seat left, 5 people press Buy at the same moment
check, then buy:             sold 5, refused 0, seats left 0
check and buy as one step:   sold 1, refused 4, seats left 0

Output টা পড়ি:

  • check, then buy: sold 5, seats left 0। পাঁচজনই "1" পড়েছে, পাঁচজনই check পার হয়েছে, পাঁচজনই 0 লিখেছে। Seat এর counter দেখে একদম সুস্থ মনে হয়, এজন্যই oversell সাধারণত monitoring এ ধরা পড়ে না, রাগী customer এর কাছ থেকে জানা যায়।
  • check and buy as one step: sold 1, refused 4। Lock পড়া-check-লেখা কে একটা ধাপ বানিয়ে দিয়েছে। লাইনের দ্বিতীয় ক্রেতা 0 পড়েছে আর ফেরত গেছে।
  • দাম: lock সহ version এ লাগে মোটামুটি 5 × 50 ms, কারণ ক্রেতারা লাইনে অপেক্ষা করে। ঠিক থাকার দাম দিতে হয় অপেক্ষা দিয়ে। এজন্যই আসল system critical section যত ছোট সম্ভব রাখে, সবচেয়ে ভালো হয় একটা database statement।
  • Node.js ও বাদ না। Thread নেই, race একই। ফাঁকটা thread এর কারণে না; তোমার পড়া আর লেখার মাঝে অন্য কিছু চলতে পারলেই ফাঁক।

আসল engineering

  • সিদ্ধান্তটা database কে দাও। Code এ পড়ে তারপর লেখার বদলে UPDATE ... WHERE stock >= 1 লিখে rows affected দেখো, বা unique constraint সহ INSERT করো। Database সব server এর সব request দেখে।
  • Transaction কাজে লাগে, কিন্তু খুঁটিনাটি দেখে নাও। Transaction কয়েকটা statement কে একসাথে বাঁধে, কিন্তু অনেক database এর default isolation level এ দুটো transaction তবুও একই value পড়ে দুটোই লিখতে পারে। Check-then-act এর জন্য SELECT ... FOR UPDATE (row lock) বা আরো কড়া isolation level লাগে। (Module 6, lesson 11–13।)
  • Optimistic concurrency। একটা version column রাখো। লেখো এভাবে UPDATE ... SET qty = 3, version = 8 WHERE id = 1 AND version = 7। মাঝে কেউ না লিখলে সফল; 0 row বদলালে বুঝবে কেউ লিখেছে, তাই আবার পড়ে আবার চেষ্টা করো। Conflict কম হলে দারুণ কাজ করে।
  • Idempotency কাছাকাছি সমস্যা। একজন user দুবার Buy চাপলে (বা timeout এর পর retry হলে) সেটাও একই সমস্যা, শুধু একজন মানুষ থেকে। Idempotency key দিলে দ্বিতীয় চেষ্টায় আর কিছু হয় না। (Module 3 lesson 13, Module 7 lesson 10।)
  • ইচ্ছা করে test করো। Test এ একই জিনিসের ওপর একসাথে অনেক request পাঠাও, আর Go র -race detector বা Java র stress tool চালাও। যে race দেখা দিতে ভাগ্য লাগে, production এ সেটা দেখা দেবেই।

যখন ভাঙে

প্রতিটা case চালিয়ে দেখো

একটা failure বাছো

stock = 0, আজ ওই item এর order: 37টা (stock ছিল 30)
ভাঙে: Stock এর ওপর check-then-act। ধরার উপায়: ব্যস্ত সময়ের order আর stock এর হিসাব মিলিয়ে দেখা। সমাধান: শর্ত সহ কমানো (WHERE stock >= qty) বা row lock।

পড়ার আগে ভাবো

তুমি buy এর code এর চারপাশে একটা lock দিলে, test এ নিখুঁত কাজ করছে। তারপর load balancer এর পেছনে server এর দুটো copy deploy করলে। Oversell কি আবার ফিরবে?

উত্তর দেখো

হ্যাঁ। প্রত্যেক copy র নিজের memory তে নিজের lock (lesson 5)। Copy A র ক্রেতা আর copy B র ক্রেতা কখনো একে অপরের জন্য অপেক্ষা করে না, তাই দুজনই 1 পড়ে দুজনই বেচে দিতে পারে। Process এর ভেতরের lock একটা process কেই রক্ষা করে। একাধিক process হলে সিদ্ধান্ত নিতে দাও database কে (conditional update, row lock, unique constraint), নয়তো এমন lock ব্যবহার করো যেটা একটা shared জায়গায় থাকে (Module 8)।

নিজেকে check করো

1. এক বাক্যে race condition কী?

উত্তর

এমন একটা bug যেখানে একসাথে চলা কাজের timing এর ওপর ফল নির্ভর করে, তাই একই code কখনো ঠিক, কখনো ভুল।

2. Lock ছাড়া version এ শেষে "seats left 0" কেন, যেটা দেখতে ঠিকই লাগে?

উত্তর

প্রত্যেক ক্রেতা একই পুরনো পড়া থেকে "1 − 1 = 0" লিখেছে। লেখাগুলো একই সংখ্যা দিয়ে একটা আরেকটার ওপর বসে গেছে, তাই সংখ্যা ঠিক দেখায়, অথচ ticket গেছে পাঁচটা।

3. Check-then-act নিরাপদ করার তিনটা উপায় বলো।

উত্তর

Critical section এর চারপাশে একটা lock; একটা atomic conditional write (UPDATE ... WHERE left >= 1, তারপর rows affected দেখা); একটা unique constraint যাতে database নিজেই duplicate ফিরিয়ে দেয়। (Version column দিয়ে optimistic concurrency চতুর্থ উপায়।)

4. কয়েকটা server চালালে তোমার Go বা Java code এর mutex কেন যথেষ্ট না?

উত্তর

Mutex থাকে একটা process এর memory তে। অন্য copy র request গুলো এটা দেখতেই পায় না, তাই তারা তবুও একটার ভেতরে আরেকটা ঢুকে যেতে পারে। Shared সিদ্ধান্তটা নিতে হবে এমন জায়গায় যেটা সব copy share করে, সাধারণত database।

তোমার challenge: lock ছাড়া

Lock ছাড়া ক্রেতার একটা তৃতীয় version লেখো, compare-and-swap দিয়ে: একটা atomic কাজ যেটা বলে "seat এর সংখ্যা left − 1 করো, শুধু যদি এখনো left থাকে"। Go তে আছে atomic.Int64.CompareAndSwap, Java তে AtomicInteger.compareAndSet, Rust এ AtomicI64::compare_exchange; Python আর Node.js এ একটা ছোট function দিয়ে এটা বানাও, যেটা তুলনা আর লেখা মাঝে কোনো বিরতি ছাড়া করে।

Swap fail করলে বুঝবে অন্য কেউ আগে ঢুকে গেছে: আবার পড়ো, আবার চেষ্টা করো।

সফল হয়েছো যখন নতুন লাইনটা sold 1, refused 4, seats left 0 print করে, lock সহ version এর চেয়ে অনেক কম সময়ে শেষ হয় (কাউকে অন্যের পুরো critical section এর জন্য বসে থাকতে হয় না), আর তুমি বুঝিয়ে বলতে পারো এটা lock এর চেয়ে কেন দ্রুত, আর বারবার চেষ্টা করা কখন সমস্যা হয়ে দাঁড়াতে পারে।

Mental model

অনেক user মানে একসাথে অনেক request, আর তাদের ধাপগুলো একটার ভেতরে আরেকটা ঢুকে যায়। Code যখনই shared state পড়ে, সিদ্ধান্ত নেয়, আবার লেখে, মাঝের ফাঁকে অন্য কেউ সেটা বদলে দিতে পারে: এটাই race। পড়া-check-লেখা কে একটা অবিভাজ্য ধাপ বানাও: একটা process এর ভেতরে lock, আর একাধিক থাকলে database এ atomic conditional write বা unique constraint।

Station stamp

Chapter শেষ করো

পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।

Pro নিয়ে XP পাও →