Məzmuna keç
AçBağlaEsc
Bloq

7 dəq oxunuş

Laravel-də atomik kilidlərlə yarış vəziyyətlərinin qarşısını almaq

İki sorğu, bir oturacaq, iki sahib: yoxla-sonra-yaz boşluğu necə vurur və nə vaxt Cache::lock, nə vaxt lockForUpdate seçməli.

Yalnız iki nəfər eyni şeyi eyni anda edəndə üzə çıxan bir xəta var. Testdə onu heç vaxt görməyəcəksən. Düyməni basırsan, işləyir. Həmkarın basır, işləyir. Sonra bir gün prodakşnda iki istifadəçi eyni bir neçə millisaniyə içində basır və bir oturacağa iki sifariş, iki dəfə ödəniş çəkilmiş müştəri və ya olmalı olduğundan çox istifadə edilmiş kupon alırsan.

Bu, yarış vəziyyətidir. Laravel-də onunla həqiqətən işləyən ən sadə alətlə necə davrandığım budur.

Problem

Deyək ki, tədbir üçün oturacaq satırsan. Aşkar kod belə görünür:

public function reserve(int $seatId)
{
    $seat = Seat::findOrFail($seatId);
 
    if (! $seat->is_available) {
        abort(409, 'Seat already taken');
    }
 
    $seat->update([
        'is_available' => false,
        'user_id'      => auth()->id(),
    ]);
 
    return $seat;
}

Yaxşı oxunur. Boş olub-olmadığını yoxla, sonra götür. Problem yoxlama ilə yeniləmə arasındakı boşluqdadır. Kiçikdir, bir neçə millisaniyə, amma var.

İndi iki sorğunun demək olar eyni anda gəldiyini təsəvvür et:

  1. A sorğusu oturacağı yükləyir. is_available true-dur.
  2. B sorğusu oturacağı yükləyir. is_available hələ də true-dur, A hələ yadda saxlamayıb.
  3. A sorğusu yoxlamadan keçir, sətri yeniləyir.
  4. B sorğusu da yoxlamadan keçir (artıq true oxuyub), sətri yeniləyir.

Hər iki istifadəçi uğur səhifəsi görür. Bir oturacaq, iki sahib. Və loqlarda heç nə səhv görünmür, çünki hər sorğu öz-özlüyündə kodun dediyini etdi.

Eyni forma hər yerdə görünür: "balansı yoxla, sonra çıx", "stoku yoxla, sonra sat", "kuponun istifadə olunub-olunmadığını yoxla, sonra istifadə olunmuş işarələ". Nə vaxt ki nəyisə oxuyub oxuduğuna əsasən yazırsan, iki sorğu boşluqdan birlikdə keçə bilər.

Həll: eyni anda yalnız bir sorğu içəri girsin

İstədiyimiz sadədir. Verilmiş oturacaq üçün həmin yoxla-və-yenilə blokunu eyni anda yalnız bir sorğu icra edə bilməlidir. Qalanları növbəsini gözləməlidir.

Laravel-in atomik kilidləri məhz bunu edir:

use Illuminate\Support\Facades\Cache;
 
public function reserve(int $seatId)
{
    return Cache::lock("seat:{$seatId}", 10)->block(5, function () use ($seatId) {
        $seat = Seat::findOrFail($seatId);
 
        if (! $seat->is_available) {
            abort(409, 'Seat already taken');
        }
 
        $seat->update([
            'is_available' => false,
            'user_id'      => auth()->id(),
        ]);
 
        return $seat;
    });
}

İki rəqəmi izah edək:

  • Cache::lock("seat:{$seatId}", 10): bu konkret oturacağın adına kilid, 10 saniyədən sonra özü bitir. Bitmə vaxtı təhlükəsizlik torudur: proses yarıda ölsə, kilid əbədi ilişib qalmır.
  • ->block(5, ...): kilidi almağa çalış, başqası tutubsa, onun bitirməsini 5 saniyəyə qədər gözlə. Alan kimi callback-i icra et və bitəndə kilidi burax.

İndi yarış belə görünür:

  1. A sorğusu seat:42 üçün kilidi götürür, oturacağı yükləyir, boşdur, rezerv edir, kilidi buraxır.
  2. B sorğusu gözləyirdi. İndi kilidi alır, oturacağı yükləyir, is_available-ın false olduğunu görür və 409 alır.

Bir oturacaq, bir sahib. İkinci istifadəçi yalançı uğur əvəzinə aydın "artıq tutulub" mesajı alır.

Diqqət et ki, kilid oturacaq üzrədir. Fərqli oturacaqları rezerv edən iki nəfər bir-birini heç gözləmir. Bu vacibdir: bütün tətbiqi tək sıralı növbəyə çevirən bir qlobal kilid istəmirsən.

Bilməyə dəyən bir-iki xırda şey:

  • block gözləmə vaxtı ərzində kilidi ala bilməsə, Illuminate\Contracts\Cache\LockTimeoutException atır. Belə qısa blok üçün nadirdir, amma 500 əvəzinə "yenidən cəhd edin" mesajı göstərmək üçün tutmağa dəyər.
  • Atomik kilidlər onları dəstəkləyən keş drayveri tələb edir. Redis, Memcached, database və file drayverlərinin hamısı dəstəkləyir. array drayveri də işləyir, amma yalnız bir proses daxilində, yəni testlər üçün yaxşıdır, prodakşnda faydasızdır.

Digər seçim: verilənlər bazasında sətir kilidləri

Bunun üçün həmişə keş lazım deyil. Qoruduğun şey bir verilənlər bazası sətrində yaşayırsa, həmin sətri sənin üçün kilidləməyi bazanın özündən istəyə bilərsən:

public function reserve(int $seatId)
{
    return DB::transaction(function () use ($seatId) {
        $seat = Seat::where('id', $seatId)
            ->lockForUpdate()
            ->firstOrFail();
 
        if (! $seat->is_available) {
            abort(409, 'Seat already taken');
        }
 
        $seat->update([
            'is_available' => false,
            'user_id'      => auth()->id(),
        ]);
 
        return $seat;
    });
}

lockForUpdate() MySQL-ə (və ya Postgres-ə) deyir ki, tranzaksiya bitənə qədər həmin sətri saxlasın. Eyni sətri lockForUpdate() etməyə çalışan hər hansı başqa tranzaksiya sadəcə gözləyir. Keş kilidi ilə eyni nəticə, amma işi verilənlər bazası görür.

Bu, yalnız tranzaksiya içində işləyir. Tranzaksiyasız kilid sorğu bitən kimi buraxılır, yəni yeniləmən işə düşməmişdən əvvəl, və ilkin xətaya qayıdırsan.

Hansını istifadə etməli?

Mən bunu təxmini bələdçi kimi işlədirəm:

lockForUpdate istifadə et, əgər bütün əməliyyat "sətri oxu, eyni sətri yaz, bitdi"dirsə və hamısı bir verilənlər bazasındadırsa. Daha sadədir, keş qarışmır və kilid tranzaksiya commit olunanda və ya uğursuz olanda özü yox olur.

Cache::lock istifadə et, əgər kritik hissə bir sətirdən böyükdürsə. Məsələn: oturacağı yoxla, ödəniş API-sini çağır, sonra yadda saxla. Xarici HTTP çağırışını gözləyərkən verilənlər bazası tranzaksiyasını açıq saxlamaq istəmirsən, amma ikinci sorğunun eyni axını başlatmasını yenə də dayandırmaq istəyirsən. Keş kilidi bu boşluğu təmiz örtür. Qoruduğun şey ümumiyyətlə verilənlər bazası sətri deyilsə də, məsələn "bu hesabat generasiyasını eyni anda iki dəfə işlətmə", yeganə seçimdir.

Hər ikisi yaxşıdır. Vacib olan odur ki, iki dəfə baş verməməli olan bir şeydə "yoxla, sonra yaz" nümunəsini görəndə birini istifadə edəsən.

if yoxlaması haqqında qeyd

Bəzən soruşurlar: kilid iki sorğunun birlikdə işləməsini dayandırırsa, içəridəki is_available yoxlaması hələ də lazımdır?

Bəli. Kilid sadəcə sorğuları növbəyə qoyur. Birinci sorğunun nə etdiyini bilmir. İkinci sorğu yenə də sətrə baxıb oturacağın artıq getdiyini aşkar etməlidir. Kilid sıralamanı verir, yoxlama qərarı. İkisi də lazımdır.

Yekun

Yarış vəziyyətləri ona görə bezdiricidir ki, real trafik olana qədər görünmür, görünəndə isə hər sorğu öz-özlüyündə günahsız görünür. Həll isə kiçikdir: yoxla-və-yaz hissəsini kilidə bük ki, eyni anda içəridə yalnız bir sorğu ola bilsin. Ümumi hal üçün Cache::lock, söhbət bir sətirdən gedəndə lockForUpdate.

Nümunəni bir dəfə görəndən sonra onu çox yerdə görməyə başlayırsan. Bu yaxşı şeydir. Kilidi əvvəlcədən əlavə etmək müştəriyə oturacağının niyə iki dəfə satıldığını izah etməkdən xeyli ucuzdur.