Pessimistic Lock & Optimistic Lock
קצת רקע על מסדי נתונים:
מסד נתונים רלציוני (Relational / SQL)
מסד נתונים שבו המידע מנוהל בטבלאות (שורות ועמודות), בדומה לגיליונות אקסל. הטבלאות מקושרות זו לזו באמצעות מפתחות (key). מסדי נתונים אלו מחויבים לכללי ACID (עקביות וטרנזקציות נוקשות), מה שאומר שהם אולטימטיביים לשמירה על שלמות מידע פיננסי או מידע מורכב.
- PostgreSQL
- MySQL
- Microsoft SQL Server
- Oracle Database
- SQLite (קל משקל, נפוץ באפליקציות מובייל)
מסד נתונים לא-רלציוני (Non-Relational / NoSQL)
מסד נתונים שאינו כבול למבנה טבלאי נוקשה. המידע נשמר בדרכים גמישות יותר - כמו מסמכי JSON, מפתחות-ערך, גרפים או עמודות רחבות. הם מתוכננים לתת מענה לביצועים גבוהים, סקלביליות מסיבית (יכולת גידול לשרתים רבים) וטיפול בנתונים משתנים או בלתי מובנים.
- MongoDB (מבוסס מסמכים / Document)
- Redis (מפתח-ערך מהיר במיוחד בזיכרון / In-Memory Cache)
- Cassandra / ScyllaDB (מבוססי עמודות רחבות לעומסים אדירים)
- Neo4j (מסד נתונים גרפי לניהול רשתות וקשרים מורכבים)
עכשיו כשיש לנו ידע בסיסי בסוגי מסדי נתונים, אנחנו יכולים לדון בגישות השונות.
במסדי נתונים רלציוניים מסורתיים (כמו PostgreSQL), בדרך כלל עובדים על שרת בודד (או קלאסטר מוגבל) עם דגש חזק על עקביות מוחלטת (Consistency), ולכן קל מאוד להפעיל בהם נעילות פסימיות.
ב-NoSQL, המערכות בנויות לרוב סביב זמינות (Availability) וביזור (Partition Tolerance). לכן:
- נעילות פסימיות פיזיות כמעט ולא קיימות שם, כי הן יוצרות צווארי בקבוק שמנוגדים לפילוסופיית ה-NoSQL.
- נעילה אופטימית (Optimistic Concurrency Control - OCC) היא הבחירה הטבעית והנפוצה ביותר בעולם ה-NoSQL כשצריך להבטיח שלא יהיו דריסות נתונים.
מנגנוני Locking (נעילות) במסדי נתונים רלציוניים (SQL) הם הדרך שלנו להתמודד עם Race Conditions - מצבים שבהם שני משתמשים או שני תהליכים מנסים לעדכן את אותה שורה במקביל, מה שעלול לגרום לאובדן נתונים (Lost Update).
Pessimistic Lock
הגישה הפסימית אומרת: "אנחנו מניחים שתמיד יהיו התנגשויות (Conflicts). לכן, מהרגע שקראתי את השורה ועד שסימנתי את העבודה עליה, אני אנעל פיזית את השורה בבסיס הנתונים כדי שאף אחד אחר לא יוכל לקרוא או לעדכן אותה".
נפוץ בעיקר במצבים הבאים (אבל לא רק):
- פעולות פיננסיות רגישות: למשל, משיכת מזומן מכספומט או העברה בנקאית שבה אסור בשום אופן שיהיה מצב של Race Condition.
- מלאי מוגבל מאוד: כרטיסים להופעה אחרונה שנותרו מהם 2 כרטיסים בלבד ויש עשרות קונים במקביל.
- טרנזקציות ארוכות ומורכבות: שבהן יש סיכוי גבוה ששתי טרנזקציות ינסו לשנות את אותו מידע במקביל.
איך משלבים אותו בשאילתא? (SQL)
ברוב מסדי הנתונים הרציונלים SELECT ... FOR UPDATE:
BEGIN TRANSACTION;
SELECT * FROM Accounts WHERE id = 123 FOR UPDATE; // lock this data in DB
UPDATE Accounts SET balance = balance - 100 WHERE id = 123; // update the data
COMMIT; // realese the data
שיקולים, יתרונות וחסרונות:
- יתרון: בטיחות מוחלטת. אין סיכוי שמישהו יצליח לדרוס את הנתונים שלך תוך כדי תנועה.
- חיסרון משמעותי (ביצועים): פוגע ב-Concurrency (מקביליות). אם טרנזקציה אחת נועלת שורה, כל השאר נעצרים וממתינים (Blocking). זה עלול ליצור צווארי בקבוק, האטה קשה במערכת, ואפילו Deadlocks.
Optimistic Lock
הגישה האופטימית אומרת: "אנחנו מניחים שהתנגשויות הן דבר נדיר. לכן, אני קורא את הנתונים בלי לנעול כלום. בזמן העדכון, אנחנו נבדוק האם מישהו אחר שינה את הנתונים מאז שקראתי אותם. אם כן - העדכון ייכשל ואתמודד עם זה. אם לא - העדכון יעבור בהצלחה".
איך עושים את זה בפועל? בדרך כלל מוסיפים לטבלה עמודת Version (מספר גרסה רץ) או Timestamp (חותמת זמן).
נפוץ בעיקר במצבים הבאים (אבל לא רק):
- מערכות עם הרבה קריאות ומעט עדכונים (Read-heavy): למשל, מערכות ניהול תוכן (CMS), פרופילים של משתמשים, או עריכת פריטים בקטלוג.
- טרנזקציות ארוכות מצד המשתמש: למשל, טופס ארוך שמשתמש ממלא במשך 10 דקות (אי אפשר לנעול את השורה במסד הנתונים כל הזמן הזה).
איך משלבים אותו בשאילתא? (SQL)
UPDATE Products
SET stock = stock - 1,
version = version + 1
WHERE id = 456 AND version = 5;
איך יודעים אם הייתה התנגשות? בודקים כמה שורות עודכנו (Rows Affected):
- אם התוצאה היא 1, סימן שהכל תקין (אף אחד אחר לא נגע בשורה בזמן הזה).
- אם התוצאה היא 0, סימן שמישהו אחר כבר הספיק לעדכן את השורה לפניך ולכן ה-version כבר אינו 5. במצב זה המערכת בדרך כלל זורקת חריגה (למשל OptimisticLockException) ומאפשרת למשתמש לנסות שוב.
שיקולים, יתרונות וחסרונות:
- יתרון (ביצועים גבוהים): אין נעילות בבסיס הנתונים בזמן הקריאה או ההמתנה. המערכת מאוד מהירה ומאפשרת מקביליות גדולה.
- חיסרון: אם יש הרבה התנגשויות אמיתיות (למשל אלפים שמנסים לקנות את אותו מוצר באותו שנייה), המון טרנזקציות ייכשלו, יזרקו שגיאות, ויצטרכו לחזור על התהליך (Retry Loop), מה שייצור עומס מיותר.
דגשים לשימוש בשתי הגישות:
- שילוב עם ORM (כמו Hibernate / JPA): ברוב סביבות הפיתוח המודרניות (כמו Java/Hibernate), אין צורך לכתוב את השאילתות ידנית. ב-Hibernate למשל, מספיק להוסיף את האנוטציה @Version מעל שדה הגרסה באובייקט ה-Entity, והפריימוורק כבר ידאג אוטומטית לבדוק את הגרסה בכל פעולת UPDATE.
- Deadlocks בנעילה פסימית: כשמשתמשים בנעילות פסימיות מרובות (למשל לנעול גם חשבון א' וגם חשבון ב' במקביל), חייבים להקפיד לנעול אותם תמיד באותו סדר (למשל תמיד לפי ID מהקטן לגדול), אחרת תיצרו מצב של Deadlock שבו שתי טרנזקציות מחכות זו לזו לנצח.
- Retry Logic בנעילה אופטימית: אם בחרתם באופטימית, תצטרכו לתכנן את הקוד שלכם כך שידע לתפוס את השגיאה (למשל StaleObjectStateException) ולבצע ניסיון חוזר (Retry) אוטומטי למשיכת הנתונים העדכניים וביצוע הפעולה מחדש.