RE:NODE

Valkey-ის ტრანზაქციები და Lua სკრიპტები: MULTI, WATCH, EVAL

ავტორი RE:NODE-ის გუნდი
17 წუთის საკითხავი


Valkey ბრძანებებს სათითაოდ ასრულებს, ამიტომ თითოეული ცალკეული ბრძანება უკვე ატომურია. პრობლემა იწყება მაშინ, როცა შენს ლოგიკას რამდენიმე ბრძანება სჭირდება - მნიშვნელობის წაკითხვა, გადაწყვეტილება, შედეგის ჩაწერა - რადგან ნაბიჯებს შორის სხვა კლიენტი შეიძლება ჩაერიოს. ამისთვის ოთხი ინსტრუმენტი არსებობს, სიმძლავრის ზრდის მიხედვით: ერთი ატომური ბრძანება, როგორიცაა INCR ან SET ... NX, MULTI/EXEC ტრანზაქცია, ოპტიმისტური ბლოკირება WATCH-ით და Lua სკრიპტი, რომელსაც EVAL-ით გაუშვებ. აიღე ყველაზე მარტივი, რომელიც ხარვეზს ახურავს. „შეამოწმე და მერე შეცვალე“ ტიპის ლოგიკისთვის უმეტესად ეს მოკლე Lua სკრიპტია; ჩანაწერების მარტივი პაკეტისთვის, რომელშიც სხვის ბრძანებებს ჩარევა არ უნდა შეეძლოს, MULTI/EXEC-ია; ხოლო WATCH მაშინ გამოგადგება, როცა გადაწყვეტილება აპლიკაციის კოდში უნდა მიიღო.

ეს სტატია განმარტავს, რას იძლევა ზუსტად თითოეული მათგანი, რომელ გარანტიებს ვარაუდობენ ადამიანები, თუმცა ისინი არ არსებობს (დაბრუნება, rollback, არ არსებობს), და რამდენიმე ატომურ პატერნს - მრიცხველები, compare-and-delete, ლოქები, მარაგის კლება, იდემპოტენტურობის გასაღებები - რომლებიც რეალური გამოყენების უმეტეს ნაწილს ფარავს. ვარაუდობს, რომ დაკავშირება უკვე იცი; თუ არა, დაიწყე სტატიით Valkey-ის პირველი ნაბიჯები.

რატომ არის ცალკეული ბრძანებები უსაფრთხო, თანმიმდევრობები კი არა#

Valkey ბრძანებებს ერთ მთავარ ნაკადში ასრულებს. სანამ ერთი ბრძანება სრულდება, სხვა კლიენტის არცერთი ბრძანება არ სრულდება, ამიტომ INCR counter არასოდეს დაკარგავს განახლებას ისე, როგორც ამას აპლიკაციის კოდში „წაიკითხე, დაამატე ერთი, ჩაწერე“ აკეთებს. პრობლემა შენს კოდშია. წარმოიდგინე ორი worker, რომლებიც მარაგს ამცირებენ:

code
worker A: GET stock:42      -> 1worker B: GET stock:42      -> 1worker A: SET stock:42 0    (sold the last item)worker B: SET stock:42 0    (sold the same item again)

თითოეული ბრძანება ატომური იყო. თანმიმდევრობა - არა. Valkey-მ ზუსტად ის გააკეთა, რაც უთხრეს, იმ რიგით, რომელსაც შენი კოდი არ აკონტროლებდა. გამოსავალი არასდროს არის „უფრო სწრაფად“; ან წაკითხვა, გადაწყვეტილება და ჩაწერა ერთ მთლიანობად უნდა აქციო, ან უნდა დააფიქსირო, რომ მონაცემები ფეხქვეშ შეიცვალა, და თავიდან სცადო.

სანამ რამე მძიმეს მოეჭიდები, შეამოწმე, ერთი ბრძანება ხომ არ აგვარებს საქმეს. ბევრი აგვარებს:

საჭიროებაერთი ატომური ბრძანება
დათვლა დაკარგული განახლებების გარეშეINCR, INCRBY, HINCRBY, ZINCRBY
შექმნა მხოლოდ მაშინ, თუ არ არსებობსSET key value NX
შექმნა ვადით ერთ ნაბიჯშიSET key value NX EX 30
წაკითხვა და წაშლაGETDEL
წაკითხვა და TTL-ის განახლებაGETEX key EX 60
გადატანა სიებს შორისLMOVE source dest LEFT RIGHT
ყველაზე დაბალი ქულის წევრის ამოღებაZPOPMIN

SET key value NX EX 30 განსაკუთრებით მნიშვნელოვანია: ძველი ორნაბიჯიანი ფორმა, SETNX და მერე EXPIRE, შეიძლება გასაღები ვადის გარეშე დატოვოს, თუ კლიენტი ორ გამოძახებას შორის დაიღუპა. ერთ ბრძანებას ეს არ შეუძლია. სადაც ერთი ბრძანება არსებობს, ის ამჯობინე; სტატიის დანარჩენი ნაწილი იმ შემთხვევებისთვისაა, როცა არ არსებობს. მონაცემთა ტიპები, რომლებზეც ეს ბრძანებები მუშაობს, აღწერილია სტატიაში Valkey-ის მონაცემთა ტიპები.

MULTI და EXEC: რიგი და არა ლოგიკური იზოლაცია#

MULTI ტრანზაქციას ხსნის. ყოველი მომდევნო ბრძანება არ სრულდება, რიგში ჩაიდგმება და სერვერი პასუხობს QUEUED. შემდეგ EXEC მთელ რიგს თანმიმდევრობით ასრულებს, ერთ გაუწყვეტელ ბლოკად: პირველ და ბოლო ჩაყენებულ ბრძანებას შორის სხვა კლიენტის არცერთი ბრძანება არ სრულდება. შეგიძლია სცადო valkey-cli-ში, სადაც ღია ტრანზაქციისას მოწვევაში (TX) ჩანს:

code
127.0.0.1:6379> MULTIOK127.0.0.1:6379(TX)> DECR stock:42QUEUED127.0.0.1:6379(TX)> LPUSH orders:pending "order-9001"QUEUED127.0.0.1:6379(TX)> EXEC1) (integer) 02) (integer) 1

EXEC თითოეულ რიგში ჩადგმულ ბრძანებაზე თითო პასუხს აბრუნებს, იმავე რიგით. DISCARD კი რიგს გადაყრის და არაფერი იცვლება.

რას იძლევა ეს: იზოლაციას (არავინ ხედავს ნახევრად დასრულებულ მდგომარეობას) და ყველაფრის ერთბაშად შესრულებას. რას არ იძლევა - და სწორედ აქ ცდებიან ყველაზე ხშირად:

  • წაკითხულის გამოყენება მომდევნო ჩანაწერებისთვის შეუძლებელია. MULTI-ს შიგნით QUEUED-ს იღებ და არა მნიშვნელობას. ვერ გააკეთებ რიცხვის GET-ს და ვერ გამოიყენებ მას რიგის შემდეგ ბრძანებაში, რადგან GET ჯერ არ შესრულებულა. ყველა გადაწყვეტილება ტრანზაქციის გახსნამდე უნდა მიიღო.
  • დაბრუნება (rollback) არ არსებობს. თუ ერთი ბრძანება შესრულებისას ჩავარდა, დანარჩენები მაინც სრულდება. ტრანზაქცია, რომელიც INCR-ს ისეთ გასაღებზე ასრულებს, სადაც ტექსტია, ამ ერთ ბრძანებაზე შეცდომას შეგატყობინებს და დანარჩენს მშვიდად გამოიყენებს.
  • ორი სახის წარუმატებლობა სხვადასხვანაირად მუშავდება. თუ ბრძანება უკვე რიგში ჩადგმისას არასწორია (უცნობი ბრძანება, არგუმენტების არასწორი რაოდენობა), სერვერი შეცდომას დაიმახსოვრებს და EXEC მთელ ტრანზაქციას EXECABORT-ით უარყოფს. თუ ბრძანება სწორადაა აგებული, მაგრამ შესრულებისას ვარდება (არასწორი ტიპის მნიშვნელობა), მხოლოდ ის ვარდება.
code
127.0.0.1:6379> SET name "alice"OK127.0.0.1:6379> MULTIOK127.0.0.1:6379(TX)> INCR nameQUEUED127.0.0.1:6379(TX)> SET other 1QUEUED127.0.0.1:6379(TX)> EXEC1) (error) ERR value is not an integer or out of range2) OK

other ჩაიწერა, მიუხედავად იმისა, რომ INCR name ჩავარდა. თუ SQL-იდან მოდიხარ, ეს ის სიურპრიზია, რომლისთვისაც მზად უნდა იყო: Valkey-ის ტრანზაქციები გაუწყვეტელი პაკეტია და არა სამუშაოს ერთეული, რომლის გაუქმებაც შეიძლება. მიზეზი დიზაინის არჩევანია - rollback-ს გაუქმების ჟურნალი სჭირდება, პროექტი კი სწრაფ და მარტივ სერვერს ამჯობინებს იმ მოსაზრებით, რომ ბრძანების წარუმატებლობა შესრულებისას პროგრამისტის შეცდომაა, რომელიც დამუშავებისას უნდა დაიჭირო.

MULTI მხოლოდ pipelining არ არის

Pipelining ბევრ ბრძანებას აგზავნის თითოეულზე პასუხის ლოდინის გარეშე; ის round trip-ებს ზოგავს, მაგრამ სხვა კლიენტების ბრძანებები შეიძლება შენსებს შორის შესრულდეს. MULTI/EXEC ამატებს გარანტიას, რომ ეს არ მოხდება. კლიენტის ბიბლიოთეკების უმეტესობა ორივეს ერთმანეთში ურევს, ამიტომ შეამოწმე, რას იყენებ:

transaction.js
// ioredis: .multi() is a real MULTI/EXEC; .pipeline() is notconst results = await valkey  .multi()  .decr("stock:42")  .lpush("orders:pending", "order-9001")  .exec();// results: [[null, 0], [null, 1]]  (error, value) per command
transaction.py
# redis-py: transaction=True (the default) wraps the pipeline in MULTI/EXECpipe = r.pipeline(transaction=True)pipe.decr("stock:42")pipe.lpush("orders:pending", "order-9001")print(pipe.execute())        # [0, 1]

redis-py-ში pipeline(transaction=False) იმავე ბრძანებებს საერთოდ MULTI-ს გარეშე გზავნის. ეს უფრო სწრაფია და სრულიად სწორია დამოუკიდებელი ჩანაწერების პაკეტისთვის, მაგრამ არასწორია ყველაფრისთვის, რასაც ჩაურევლობის გარანტია სჭირდება.

WATCH: ოპტიმისტური ბლოკირება#

მარტო MULTI ვერ აკეთებს „წაიკითხე ბალანსი და გამოაკელი მხოლოდ მაშინ, თუ ჰყოფნის“. გამოტოვებულ ნაწილს WATCH იძლევა. სერვერს ეუბნები, რომელი გასაღებები გაინტერესებს; თუ რომელიმეს სხვა კლიენტი შეცვლის შენს EXEC-მდე, ტრანზაქცია არ სრულდება და EXEC ცარიელ (null) პასუხს აბრუნებს. მაშინ თავიდან კითხულობ და თავიდან ცდი. მას ოპტიმისტურს იმიტომ ეძახიან, რომ არაფერი იბლოკება: ვარაუდობ, რომ არავინ ჩაერევა, და იმ შემთხვევას ამუშავებ, როცა ჩაერია.

თანმიმდევრობა ყოველთვის ერთნაირია:

  1. გააკეთე WATCH იმ გასაღებებზე, რომელთა წაკითხვასაც აპირებ.
  2. წაიკითხე ისინი ჩვეულებრივად (MULTI-ს გარეთ) და გადაწყვეტილება შენს კოდში მიიღე.
  3. MULTI, ჩადგი ჩანაწერები რიგში, EXEC.
  4. თუ EXEC-მა null დააბრუნა, ვიღაცამ დაკვირვების ქვეშ მყოფი გასაღები შეცვალა. დაბრუნდი პირველ ნაბიჯზე.
reply arraynull, retryYour codedecides in the appGET keyread and decideMULTI / EXECwrite if unchangedCommittedno one touched itWATCH keystart watching
WATCH ტრანზაქცია და მისი განმეორება

Python-ში ასე გამოიყურება - თანხის მოხსნა, რომელიც ნულზე ქვევით ჩასვლაზე უარს ამბობს:

withdraw.py
import redisdef withdraw(r, account, amount):    key = f"balance:{account}"    with r.pipeline() as pipe:        while True:            try:                pipe.watch(key)                balance = int(pipe.get(key) or 0)   # immediate mode after WATCH                if balance < amount:                    pipe.unwatch()                    return False                pipe.multi()                pipe.decrby(key, amount)                pipe.execute()                      # raises WatchError if changed                return True            except redis.WatchError:                continue                            # someone wrote to it - retry

რამდენიმე წესი, რომელიც გამართვის დროს დაგიზოგავს:

  • WATCH იხსნება ბრძანებებით EXEC, DISCARD, UNWATCH და კავშირის დახურვით. ტრანზაქცია შესრულდა თუ შეწყდა, დაკვირვება მოხსნილია, ამიტომ განმეორებისას WATCH თავიდან უნდა გამოიძახო.
  • WATCH კავშირს ეკუთვნის. კავშირების პულთან WATCH-ისთვის, წაკითხვებისთვის და EXEC-ისთვის ერთი და იგივე კავშირი უნდა გამოიყენო, რასაც ზემოთ pipeline() კონტექსტი აკეთებს. WATCH-ის გამოძახება პულის ერთი კავშირით და MULTI-სი სხვათი კლასიკური შეცდომაა: არც შეტყობინება, არც დაცვა.
  • დაკვირვების ქვეშ მყოფ გასაღებზე ნებისმიერი ჩაწერა ითვლება, ისიც კი, რომელიც იმავე მნიშვნელობას წერს. ცხელი გასაღები მძიმე კონკურენციისას განმეორების ციკლს ტყუილად ატრიალებს; შეზღუდე მცდელობების რაოდენობა და გასაგები შეცდომით დაასრულე, უსასრულოდ ნუ იტრიალებ.
  • ჯერ არარსებულ გასაღებზე დაკვირვება დაშვებულია და სასარგებლოა: მისი შექმნა ცვლილებად ითვლება, ასე ამჩნევ ორი კლიენტის რბოლას, რომლებიც ერთსა და იმავეს ინიციალიზებენ.

WATCH სწორი არჩევანია, როცა გადაწყვეტილებას აპლიკაციის ისეთი ლოგიკა სჭირდება, რომლის Lua-ში ჩატევაც გონივრული არ არის - სხვა სერვისის გამოძახება, JSON-ის დამუშავება შენი ბიბლიოთეკებით. ლოგიკისთვის, რომელიც Lua-ს რამდენიმე ხაზში ეტევა, სკრიპტი უფრო მარტივია და განმეორების ციკლი საერთოდ არ სჭირდება, რადგან მას ვერაფერი წყვეტს.

EVAL: Lua-ს გაშვება სერვერზე#

EVAL სერვერზე Lua სკრიპტს აგზავნის, რომელიც ერთ ატომურ ნაბიჯად სრულდება. სანამ ის მუშაობს, სხვა ბრძანება არ სრულდება, ამიტომ წაკითხვა, გადაწყვეტილება და ჩაწერა ვერ აირევა სხვისთან - WATCH-ის გარეშე, განმეორებების გარეშე, ერთი round trip-ით. ბრძანების ფორმა ასეთია:

code
EVAL script numkeys key [key ...] arg [arg ...]

numkeys ამბობს, რამდენი მომდევნო სიტყვაა გასაღების სახელი; სკრიპტში ისინი მასივად KEYS ჩანს, მათ შემდეგ ყველაფერი კი ARGV-ად. ორივე მასივი 1-დან იწყება, როგორც Lua-ში მიღებულია. სკრიპტი, რომელიც მარაგს მხოლოდ საკმარისობისას ამცირებს:

take_stock.lua
-- KEYS[1] stock key; ARGV[1] quantity wantedlocal have = tonumber(redis.call("GET", KEYS[1]) or "0")local want = tonumber(ARGV[1])if have < want then  return -1endreturn redis.call("DECRBY", KEYS[1], want)

ბრძანების ხაზიდან მას --eval-ით უშვებ. შეხედე მარტოხელა მძიმეს, რომელიც გასაღებებს არგუმენტებისგან ყოფს და ორივე მხარეს ინტერვალი აქვს - ეს valkey-cli-ის სინტაქსია და არა Lua-სი:

bash
$ valkey-cli -h db.example.net -p 6380 --askpass --eval take_stock.lua stock:42 , 3(integer) 17

ან უშუალოდ, სადაც numkeys-ს თვითონ მიუთითებ:

bash
$ valkey-cli -h db.example.net -p 6380 --askpass \    EVAL "return redis.call('GET', KEYS[1])" 1 stock:42"20"

სკრიპტის შიგნით Valkey-ის ბრძანებებს redis.call(command, arg, ...)-ით უშვებ. Valkey-ის ახალი ვერსიები ფსევდონიმ server.call-საც იღებს, მაგრამ redis.call Valkey-ზეც მუშაობს და ძველ Redis-ზეც, ამიტომ ეს უფრო უსაფრთხო ჩანაწერია, თუ შენი სკრიპტები სხვაგან გადასვლას გეგმავენ. რამდენიმე დეტალი, რომელიც პრაქტიკაში მნიშვნელოვანია:

  • ყველა გასაღები, რომელსაც ეხები, `KEYS`-ში გამოაცხადე. სკრიპტები, რომლებიც გასაღებების სახელებს Lua კოდის შიგნით აგებენ, ერთ სერვერზე მუშაობს, მაგრამ კლასტერში ირღვევა, სადაც სერვერმა უნდა იცოდეს, რომელ გასაღებებს იყენებს სკრიპტი, რომ სწორად მარშრუტიზაცია გაუკეთოს. კლასტერს თუ არ გეგმავ, გასაღებების გამოცხადება მაინც არაფერი ღირს.
  • მნიშვნელობები Lua-ს ტიპებად მოდის და პასუხებად ბრუნდება. Lua-ს რიცხვი მთელი რიცხვის პასუხად გარდაიქმნება (წილადი ნაწილი იკარგება), ცხრილი - მასივად, false - nil-ად. თუ ათწილადი გჭირდება, დააბრუნე სტრიქონად tostring-ით.
  • `tonumber` არითმეტიკამდე. ARGV-ში ყველაფერი სტრიქონია, ისევე როგორც GET-ის პასუხები.
  • შეცდომები. redis.call სკრიპტს აჩერებს და კლიენტს შეცდომას უბრუნებს, როცა ბრძანება ვარდება. redis.pcall შეცდომას ცხრილად აბრუნებს, ასე რომ სკრიპტს მისი დამუშავება შეუძლია. არცერთი არ აუქმებს ჩანაწერებს, რომლებიც სკრიპტმა უკვე გააკეთა - MULTI-ს მსგავსად, სკრიპტი, რომელიც შუაში ვარდება, ადრინდელ ჩანაწერებს ადგილზე ტოვებს. ჯერ შემოწმებები, ჩაწერები ბოლოს.
  • დრო არგუმენტად გადაეცი, თუ შეგიძლია, სკრიპტის შიგნით საათზე დაყრდნობის ნაცვლად; მაშინ სკრიპტი თავისი შეყვანების წმინდა ფუნქციაა, რასაც ბევრად უფრო ადვილად ტესტავ.

EVALSHA და სკრიპტების ქეში

სკრიპტის სრული ტექსტის ყოველ გამოძახებაზე გაგზავნა ფუჭი ხარჯია. SCRIPT LOAD სკრიპტს სერვერზე ინახავს და მის SHA1 ჰეშს აბრუნებს, EVALSHA კი სკრიპტს ჰეშით უშვებს:

bash
$ valkey-cli -h db.example.net -p 6380 --askpass SCRIPT LOAD "$(cat take_stock.lua)""9a7f1c0e5b2d8a64c3e19f0b7d25a8c4e6f13b02"$ valkey-cli -h db.example.net -p 6380 --askpass \    EVALSHA 9a7f1c0e5b2d8a64c3e19f0b7d25a8c4e6f13b02 1 stock:42 3(integer) 14

ზემოთ მოცემული ჰეში საილუსტრაციოა, შენი განსხვავებული იქნება. სამი ფაქტი სკრიპტების ქეშზე, რომლებიც რეალურ ინციდენტებს იწვევს:

  • ის მეხსიერებაში ცხოვრობს და პერსისტენტობის მექანიზმები მას არ ინახავს, ამიტომ გადატვირთვის შემდეგ EVALSHA პასუხობს NOSCRIPT-ით. რას ინახავს გადატვირთვა, იხილე სტატიაში AOF და RDB პერსისტენტობა.
  • კლიენტის ბიბლიოთეკები ამას ასე აგვარებენ: ჯერ EVALSHA-ს ცდიან, NOSCRIPT-ზე კი სრული ტექსტით EVAL-ზე გადადიან. თუ EVALSHA-ს ხელით იძახებ, იგივე უნდა გააკეთო.
  • SCRIPT EXISTS sha [sha ...] გეუბნება, არის თუ არა ჰეშები ქეშში, SCRIPT FLUSH ქეშს ასუფთავებს, ქეში კი სერვერზეა და არა მონაცემთა ბაზაზე.

ფუნქციები Redis 7-სა და Valkey-ში

ახალი ვერსიები ამატებენ FUNCTION LOAD-სა და FCALL-ს, რომლებიც სერვერზე სახელდებულ, ვერსიულ Lua ფუნქციების ბიბლიოთეკებს ინახავენ; ეს ფუნქციები მონაცემებთან ერთად ინახება და გადატვირთვას გადაურჩება. ასე NOSCRIPT-ის პრობლემა იხსნება, ოღონდ დამატებითი ნაბიჯის ფასად დანერგვისას: ბიბლიოთეკას deploy-ის დროს ტვირთავ და არა კლიენტი ზარმაცად ტვირთავს სკრიპტებს. აპლიკაციების უმეტესობისთვის EVAL კლიენტის ბიბლიოთეკით უფრო მარტივი გზაა; ფუნქციებზე მაშინ იფიქრე, როცა ერთსა და იმავე სერვერულ ლოგიკას ბევრი სერვისი იყენებს. მათზე დაყრდნობამდე სერვერის ვერსია INFO server-ით შეამოწმე.

ატომურობის ფასი: ნელი სკრიპტი ყველას ბლოკავს

რადგან სკრიპტი ერთადერთ მთავარ ნაკადს იკავებს, ნელი სკრიპტი ყველა სხვა კლიენტს აჩერებს. სკრიპტები რამდენიმე ბრძანებით და შეზღუდული სამუშაოთი შეინარჩუნე. შიგნით არასოდეს გადაუარო შეუზღუდავ სიმრავლეს (KEYS *, უზარმაზარი სია, SMEMBERS მილიონ წევრზე). სერვერს აქვს სკრიპტის დროის ლიმიტი, ნაგულისხმევად ხუთი წამი (პარამეტრს lua-time-limit ჰქვია, ახალ ვერსიებში მას busy-reply-threshold-საც უწოდებენ); მის შემდეგ სერვერი სხვა კლიენტებს BUSY-ით პასუხობს და უშვებს SCRIPT KILL-ს, რომელიც მხოლოდ მაშინ მუშაობს, თუ სკრიპტს ჯერ არაფერი ჩაუწერია. სკრიპტი, რომელმაც უკვე ჩაწერა, მხოლოდ SHUTDOWN NOSAVE-ით გაჩერდება, რომელიც ბოლო შენახვის შემდგომ ყველაფერს აგდებს. მიიჩნიე ის მინის უკან დაკიდებულ ცეცხლსაქრობად და სკრიპტები იმდენად მოკლე გააკეთე, რომ არასოდეს დაგჭირდეს.

ატომური პატერნები, რომლებიც ცოდნად ღირს#

მრიცხველი ზედა ზღვრით

ლიმიტის ჩვეულებრივი შემოწმება - „გაზარდე, მაგრამ 100-ზე მეტად არა“ - ზუსტად ისეთი წაკითხვა-გადაწყვეტილება-ჩაწერაა, რომელსაც მარტო INCR ვერ გამოხატავს:

incr_max.lua
-- KEYS[1] counter; ARGV[1] maximum; ARGV[2] ttl in secondslocal n = redis.call("INCR", KEYS[1])if n == 1 then  redis.call("EXPIRE", KEYS[1], tonumber(ARGV[2]))endif n > tonumber(ARGV[1]) then  redis.call("DECR", KEYS[1])  return 0endreturn n

ეს ფიქსირებული ფანჯრის შემზღუდველის სამშენებლო ბლოკია. ყველა ვარიანტი, მოცურავი ფანჯრებისა და token bucket-ის ჩათვლით, განხილულია სტატიაში Valkey-ით rate limiting.

Compare-and-delete: ლოქის უსაფრთხოდ გათავისუფლება

Valkey-ში ლოქი არის გასაღები, რომელიც ბრძანებით SET lock:report <token> NX PX 30000 იქმნება: ის მხოლოდ მაშინ გამოდის, თუ ლოქს არავინ ფლობს, და 30 წამში ვადა გასდის, რომ დაცემულმა მფლობელმა ყველა სამუდამოდ არ დაბლოკოს. ტოკენი შემთხვევითი სტრიქონია, მფლობელისთვის უნიკალური. შეცდომებს გათავისუფლებისას უშვებენ. თუ გასაღებს უბრალოდ DEL-ით წაშლი, შეიძლება წაშალო ლოქი, რომელიც უკვე ვადაგასულია და სხვას აუღია. წაშლა მხოლოდ მაშინ უნდა მოხდეს, თუ მასში ისევ შენი ტოკენია, ეს კი წაკითხვაა, რომელსაც ჩაწერა მოჰყვება:

release_lock.lua
-- KEYS[1] lock key; ARGV[1] the holder's tokenif redis.call("GET", KEYS[1]) == ARGV[1] then  return redis.call("DEL", KEYS[1])endreturn 0

იგივე ფორმა - შეადარე, მერე იმოქმედე - გაძლევს „გაახანგრძლივე ჩემი ლოქი, თუ ის ისევ ჩემია“ (DEL შეცვალე PEXPIRE-ით) და „დაადასტურე ეს job, თუ ის ისევ ჩემია“ ვარიანტებს. ასეთი ლოქი იცავს ერთ სერვერზე ორი worker-ის რბოლისგან. ეს არ არის ურთიერთგამორიცხვის გარანტია ქსელის გაყოფისას ან მფლობელის ხანგრძლივი პაუზისას, ამიტომ იქ, სადაც სამუშაოს დუბლირება რეალურ ზიანს გამოიწვევს, თვით სამუშაოც იდემპოტენტური გახადე.

Check-and-set ვერსიით

ოპტიმისტური თანმხლებობის კონტროლი ერთ ჩანაწერზე WATCH-ის გარეშე: ვერსიის ნომერი ჰეშში შეინახე და ჩაწერე მხოლოდ მაშინ, როცა ის შენ წაკითხულს ემთხვევა.

cas_update.lua
-- KEYS[1] hash; ARGV[1] expected version; ARGV[2] new valuelocal v = redis.call("HGET", KEYS[1], "version")if v ~= ARGV[1] then  return 0endredis.call("HSET", KEYS[1], "value", ARGV[2])redis.call("HINCRBY", KEYS[1], "version", 1)return 1

აპლიკაცია კითხულობს HGETALL-ს, ცვლილებას აკეთებს და სკრიპტს იმ ვერსიით იძახებს, რომელიც ნახა. ნულის დაბრუნება ნიშნავს, რომ ვიღაც უფრო სწრაფი აღმოჩნდა, და აპლიკაცია წყვეტს, თავიდან წაიკითხოს და გაიმეოროს თუ არა.

იდემპოტენტურობის გასაღებები

როცა მოთხოვნა შეიძლება განმეორდეს (webhook, გადახდის callback, ორჯერ მიწოდებული რიგის შეტყობინება), ჩაიწერე, რომ ის უკვე დაამუშავე, და გამეორებები გამოტოვე. ამას ერთი ბრძანება აკეთებს:

bash
$ valkey-cli -h db.example.net -p 6380 --askpass SET idem:evt_1042 1 NX EX 86400OK$ valkey-cli -h db.example.net -p 6380 --askpass SET idem:evt_1042 1 NX EX 86400(nil)

OK ნიშნავს, რომ პირველი ხარ და სამუშაო უნდა შეასრულო; (nil) ნიშნავს, რომ ის უკვე დაკავებულია. დათქმა ეხება წარუმატებლობას დაკავებასა და დასრულებას შორის: თუ worker დაკავების შემდეგ დაიღუპა, განმეორება უარყოფილი იქნება. თუ ეს მნიშვნელოვანია, შეინახე სტატუსი („started“, „done“) და არა უბრალო ნიშანი, და დაკავებას მიანიჭე TTL, რომელიც სამუშაოსთვის საკმარისად გრძელია და დაცემის შემდეგ გამეორებისთვის საკმარისად მოკლე. როგორ უმკლავდებიან იმავე პრობლემას რიგების ბიბლიოთეკები, იხილე სტატიაში ამოცანების რიგები Valkey-ზე.

მონაცემების გადატანა გასაღებებს შორის

ელემენტის გადატანა მოლოდინის სიიდან დამუშავების სიაში ერთი LMOVE-ია, თავისთავად ატომური. წევრის გადატანა სიმრავლეებს შორის მრიცხველის განახლებით MULTI/EXEC-ის შემთხვევაა, რადგან ყველა არგუმენტი დაწყებამდე იცი:

code
MULTISMOVE tasks:todo tasks:doing task-77HINCRBY stats:tasks doing 1EXEC

თუ ამ ყველაფრიდან რამე იმ მნიშვნელობაზე იყო დამოკიდებული, რომელიც ჯერ უნდა წაგეკითხა, სკრიპტი გამოიყენე.

რომელი ავირჩიო#

სიტუაციარას მივმართო
ერთი ნაბიჯი საკმარისია (INCR, SET NX)ერთი ბრძანება
რამდენიმე ჩაწერა, ყველა შეყვანა წინასწარ ცნობილიაMULTI/EXEC
გადაწყვეტილებას აპლიკაციის კოდი ან გარე გამოძახებები სჭირდებაWATCH და განმეორების ციკლი
წაიკითხე, გადაწყვიტე, ჩაწერე რამდენიმე ხაზშიLua EVAL-ით
ერთი და იგივე ლოგიკა ბევრ სერვისშიFUNCTION ბიბლიოთეკები

Lua ყოველთვის საუკეთესო პასუხი არ არის. სკრიპტი კოდია, რომელიც სერვერზე ცხოვრობს, შენი ჩვეულებრივი ტესტებისა და deploy-ების მიღმა, ენაზე, რომელზეც შენი გუნდი შეიძლება ყოველდღე არ წერდეს. მისი გამართვა უფრო რთულია, მუშაობისას ბლოკავს და უხილავია ყველასთვის, ვინც მხოლოდ აპლიკაციას კითხულობს. გამოიყენე, როცა ატომურობა ნამდვილად მოითხოვს, თითოეული სკრიპტი პატარა გააკეთე, სკრიპტები რეპოზიტორიაში ფაილებად შეინახე და დაარქვი იმის მიხედვით, რასაც აკეთებენ. თუ ას ხაზ Lua-ს წერ, ლოგიკა სავარაუდოდ აპლიკაციაში უნდა იყოს სხვა მონაცემთა მოდელით, ან მონაცემთა ბაზაში ნამდვილი ტრანზაქციებით. სტატიის Redis: როდის გჭირდება პატიოსანი დასკვნა აქაც მოქმედებს: ყველაზე საიმედო ატომური ოპერაცია ის არის, რომელიც არ დაგჭირვებია.

ტრანზაქციები, რეპლიკაცია და პერსისტენტობა#

ტრანზაქცია ან სკრიპტი append-only ფაილში იწერება და რეპლიკებს იმ ბრძანებების სახით ეგზავნება, რომლებიც რეალურად შესრულდა, ამიტომ გადატვირთვის შემდეგ ჟურნალი შედეგებს ადგენს და არა შენს ლოგიკას უშვებს თავიდან. ამიტომ სკრიპტი, რომელმაც საათი ან შემთხვევითი რიცხვი გამოიყენა, აღდგენისას არ იცვლება. აქედან ორი შედეგი მოდის:

  • ტრანზაქცია ატომურია სხვა კლიენტების მიმართ და არა ავარიის მიმართ. თუ სერვერი ჩაწერის შუაში დაიღუპა, AOF შეიძლება ტრანზაქციის შუაში დასრულდეს; გაშვებისას Valkey ან კუდს მოჭრის, ან ჩატვირთვაზე უარს იტყვის, სანამ ფაილს არ შეაკეთებ, როგორც სტატიაშია AOF და RDB პერსისტენტობა აღწერილი. MULTI-ზე ნურაფერს ააგებ, რასაც „ყველაფერი ან არაფერი“ ტიპის გამძლეობა სჭირდება.
  • ნაგულისხმევი appendfsync everysec-ის დროს მძიმე ავარიისას ბოლო წამის ჩანაწერები შეიძლება დაიკარგოს, ტრანზაქციების ჩათვლით.

წვდომის კონტროლიც მოქმედებს. მომხმარებელს, რომელიც ACL წესებითაა შეზღუდული, სჭირდება ნებართვა EVAL/EVALSHA-ზე (კატეგორია @scripting) და ყველა ბრძანებასა და გასაღებზე, რომელსაც სკრიპტი ეხება. თუ სკრიპტი წვდომის შეცდომით ვარდება, მაშინ როცა იგივე ბრძანებები ხელით მუშაობს, აქ ეძებე; იხილე უსაფრთხოება და ACL Valkey-ში.

როგორ მუშაობს ეს RE:NODE-ის Valkey სერვერზე#

მონაცემთა ბაზების ხაზში Valkey არის Redis-ის open-source ფორკი, მუშაობს Redis-ის პროტოკოლით და დაცულია პაროლით, მისი მონაცემები თითოეული სერვერისთვის ცალკე გენერირდება. ის დისკზე AOF-ითა და სნეპშოტებით ინახავს, ამიტომ შენი ტრანზაქციებისა და სკრიპტების შედეგები გადატვირთვას გადაურჩება, როგორც ზემოთ ითქვა. დაუკავშირდი valkey-cli-თი ან ნებისმიერი Redis კლიენტით პანელიდან აღებული ჰოსტით, პორტითა და პაროლით, და ამ სტატიაში ყველაფერი ასევე გაეშვება: ზემოთ ბრძანებებში db.example.net:6380 ადგილის შემვსებია, ჩაანაცვლე შენი ჰოსტითა და პორტით. ტარიფები იწყება $2-დან თვეში, ყველაზე პატარა 256 MB-ია, რაც მრიცხველებს, ლოქებსა და იდემპოტენტურობის გასაღებებს ყოფნის. თუ სკრიპტი შენს სერვერს ბლოკავს, პანელის კონსოლში მეხსიერებისა და CPU-ის გრაფიკები ტარიფის ლიმიტების მიმართ მას სწრაფად აჩვენებს; CPU-ს ხაზი, რომელიც ლიმიტზეა მიწებებული, სანამ დაყოვნება იზრდება, ძვირი სკრიპტის დამახასიათებელი ნიშანია.

FAQ#

მხარს უჭერს თუ არა Valkey rollback-ს?

არა. DISCARD რიგს EXEC-მდე აგდებს, ხოლო არასწორად აგებული ბრძანების მქონე ტრანზაქცია მთლიანად უარყოფილია, მაგრამ როცა ბრძანებები უკვე სრულდება, შუაში წარუმატებლობა ადრინდელებს არ აუქმებს. იგივე ეხება Lua სკრიპტს, რომელიც ჩაწერის შემდეგ ვარდება. შეყვანები ჯერ შეამოწმე, ჩაწერები ბოლოს დადე.

MULTI/EXEC იგივეა, რაც pipeline?

არა. Pipeline მხოლოდ ბრძანებების გაგზავნას აჯგუფებს round trip-ების დასაზოგად, სხვა კლიენტების ბრძანებები კი შენსებს შორის შეიძლება შესრულდეს. MULTI/EXEC იძლევა გარანტიას, რომ რიგში ჩადგმული ბრძანებები ერთად, ჩარევის გარეშე შესრულდება. ზოგი ბიბლიოთეკა ორივეს „pipeline“-ს უწოდებს, ამიტომ შეამოწმე, ნამდვილად აგზავნის თუ არა შენი კოდი MULTI-ს.

როდის გამოვიყენო WATCH Lua სკრიპტის ნაცვლად?

როცა გადაწყვეტილების მიღება Lua-ს შიგნით შეუძლებელია, რადგან წაკითხვასა და ჩაწერას შორის აპლიკაციის კოდი ან სხვა სისტემის გამოძახება სჭირდება. ყველაფრისთვის, რაც მოკლე სკრიპტში ეტევა, Lua უფრო მარტივია და განმეორების ციკლს არ საჭიროებს. ერთ გასაღებზე მძიმე კონკურენციისას სკრიპტი ასევე იმარჯვებს, რადგან WATCH-ის განმეორებები გროვდება.

რატომ ვიღებ NOSCRIPT-ს გადატვირთვის შემდეგ?

სკრიპტების ქეში მეხსიერებაშია და არ ინახება, ამიტომ EVALSHA ვერ პოულობს სკრიპტს, რომელიც გადატვირთვამდე ჩაიტვირთა. გაუშვი SCRIPT LOAD თავიდან, ან კლიენტის ბიბლიოთეკას მიანდე სრული ტექსტით EVAL-ზე დაბრუნება, რასაც უმეტესობა ავტომატურად აკეთებს. FUNCTION LOAD-ით ჩატვირთული ფუნქციები კი მონაცემებთან ერთად ინახება.

შეუძლია თუ არა Lua სკრიპტს სხვა სკრიპტის გამოძახება ან დამბლოკავი ბრძანებების გამოყენება?

სკრიპტს შიგნიდან EVAL-ის გამოძახება არ შეუძლია, დამბლოკავი ბრძანებები, როგორიცაა BLPOP, სკრიპტში არ ბლოკავენ; ისინი ისე იქცევიან, თითქოს ტაიმაუტი უკვე გასულია. სკრიპტში არადამბლოკავი ბრძანებები დატოვე, ლოდინი კი კლიენტის მხარეს გააკეთე.

რამდენ ხანს შეიძლება სკრიპტი მუშაობდეს?

ცოტას. ნაგულისხმევი ლიმიტი ხუთი წამია, რის შემდეგაც სხვა კლიენტები BUSY შეცდომებს იღებენ, მაგრამ ნებისმიერი სკრიპტი, რომელიც რამდენიმე მილიწამზე მეტხანს გრძელდება, უკვე ანელებს ყველა დანარჩენს. შეზღუდე სკრიპტის სამუშაოს მოცულობა და დიდი პაკეტები ერთის ნაცვლად რამდენიმე გამოძახებად გაყავი.

გამოგადგათ?