rate limiter ითვლის, რამდენად ხშირად ხდება რაღაც, და ზღვარს მიღმა უარს ამბობს. თვლა გაზიარებული უნდა იყოს - პროცესის შიდა მთვლელი შენს ლიმიტს worker-ების რაოდენობაზე ამრავლებს და ყოველ deploy-ზე ნულდება - და ატომური უნდა იყოს, რადგან ორმა მოთხოვნამ, რომელიც ერთსა და იმავე მომენტში ამოწმებს "ეს მეხუთეა?", ორივემ "კი" არ უნდა მიიღოს. Valkey სწორედ ამაში კარგია: ყოველი ბრძანება სათითაოდ სრულდება, INCR ატომურია, გასაღებებს თავისით შეუძლიათ ვადის გასვლა, ხოლო Lua სკრიპტი ერთ შეუწყვეტელ ნაბიჯად სრულდება.
ფართოდ გამოყენებადი ოთხი ალგორითმი არსებობს: fixed window, sliding log, sliding window counter და token bucket. თითოეული Valkey-ზე რამდენიმე ხაზია, და თითოეული კიდეებზე სხვანაირად ფუჭდება. ეს პოსტი ოთხივეს აგებს, ამბობს, რომელი აირჩიო, და ფარავს გადაწყვეტილებებს, რომლებსაც ალგორითმზე მეტი მნიშვნელობა აქვს - რაზე დააფუძნებ ლიმიტის გასაღებს, რა ხდება, როცა Valkey მიუწვდომელია, და რა დაუბრუნო კლიენტს.
სად ეკუთვნის rate limit-ები და სად ჯდება Valkey#
ყოველ ლიმიტს საცავი არ სჭირდება. ლიმიტი ვებ სერვერში ან reverse proxy-ში - მაგალითად nginx-ის limit_req - ტრაფიკს აჩერებს, სანამ ის შენს აპლიკაციამდე საერთოდ მივა, Valkey-ს არ საჭიროებს და სწორი ადგილია მისამართზე დაფუძნებული უხეში flood-დაცვისთვის. Rate limit-ები და ბოროტად გამოყენება ამ ფენას და იმას ფარავს, რა დათვალო.
Valkey თავის ადგილს იმსახურებს ლიმიტებისთვის, რომლებსაც აპლიკაციის ცოდნა სჭირდება:
- ანგარიშზე ან API გასაღებზე, და არა მისამართზე: "1,000 მოთხოვნა საათში უფასო ტარიფზე".
- მოქმედებაზე: შესვლის ხუთი მცდელობა ანგარიშზე თხუთმეტ წუთში, პაროლის აღდგენის სამი წერილი საათში, ერთი რეგისტრაცია მისამართზე წუთში.
- რამდენიმე პროცესს ან სერვერს შორის, სადაც თითოეულმა ერთი და იგივე რაოდენობა უნდა დაინახოს.
- ბიზნეს მნიშვნელობის კვოტები, მაგალითად შეტყობინებები დღეში, რომლებიც restart-ს უნდა გადაურჩეს.
ნიმუში ყოველთვის ერთია: ააგე გასაღები იმისგან, რასაც ზღუდავ (rl:login:user:4182), შეასრულე მასზე ატომური ოპერაცია, წაიკითხე შედეგი, გადაწყვიტე.
Fixed window: INCR და EXPIRE#
უმარტივესი limiter მოთხოვნებს ითვლის საათთან გასწორებულ ფანჯარაში - ეს წუთი, ეს საათი - და ნულდება, როცა ფანჯარა იცვლება.
INCR rl:api:key_abc:202610081412EXPIRE rl:api:key_abc:202610081412 60 NXფანჯრის ნომერი გასაღებშია (აქ წუთი 14:12), ამიტომ ახალი ფანჯარა უბრალოდ ახალი გასაღებია. INCR ქმნის გასაღებს მნიშვნელობით 1, თუ ის არ არსებობს, და ახალ რაოდენობას აბრუნებს; თუ რაოდენობა ლიმიტზე მეტია, უარი თქვი. EXPIRE ... NX ვადას მხოლოდ მაშინ აყენებს, თუ ის დაყენებული არ არის, ამიტომ გასაღები ფანჯრის შემდეგ თავისით იწმინდება. NX ოფცია EXPIRE-ზე ხელმისაწვდომია 7.0 ბრძანებათა ნაკრებიდან, რომელიც Valkey-მ მემკვიდრეობით მიიღო; ძველ სერვერებზე ვადა მხოლოდ მაშინ დააყენე, როცა INCR-მა 1 დააბრუნა.
ორივე ბრძანება გაგზავნე pipeline-ში ან MULTI/EXEC ტრანზაქციაში, რომ მათ შორის ჩავარდნამ მთვლელი ვადის გარეშე არ დატოვოს - გასაღები, რომელსაც ვადა არასოდეს გასდის, არის მომხმარებელი, რომელიც სამუდამოდ შეზღუდულია.
სისუსტე საზღვარია. წუთში 100-იანი ლიმიტით კლიენტს შეუძლია 100 მოთხოვნა გაგზავნოს 14:12:59-ზე და კიდევ 100 - 14:13:00-ზე: 200 მოთხოვნა ორ წამში, ყველა დაშვებული. შესვლის დაცვისთვის ამას დიდი მნიშვნელობა არ აქვს. ძვირი endpoint-ის დაცვისთვის შეიძლება ჰქონდეს.
| ალგორითმი | მეხსიერება კლიენტზე | ნახტომები საზღვარზე | სიზუსტე | სირთულე |
|---|---|---|---|---|
| Fixed window | ერთი პატარა გასაღები | ლიმიტის 2x-მდე | დაბალი | ტრივიალური |
| Sliding log | ერთი ჩანაწერი მოთხოვნაზე | არ არის | ზუსტი | საშუალო |
| Sliding window counter | ორი პატარა გასაღები | გასწორებული | მიახლოებითი | დაბალი |
| Token bucket | ერთი პატარა hash | დაშვებულია bucket-ის ზომამდე, განზრახ | ზუსტი სიჩქარე | საშუალო |
Sliding log sorted set-ით#
sliding log ყოველი მოთხოვნის დროის ნიშნულს იწერს და ითვლის, რამდენი ხვდება ბოლო N წამში. Valkey-ში ეს sorted set-ია, სადაც ქულა დროის ნიშნულია.
ZREMRANGEBYSCORE rl:api:key_abc 0 (now - 60000)ZADD rl:api:key_abc now now:randomZCARD rl:api:key_abcPEXPIRE rl:api:key_abc 60000წაშალე ყველაფერი, რაც ფანჯარაზე ძველია, დაამატე ეს მოთხოვნა, დათვალე, რა დარჩა, და განაახლე ვადა, რომ უმოქმედო კლიენტების გასაღებები გაქრეს. წევრი უნიკალური უნდა იყოს - ერთსა და იმავე მილიწამში ორი მოთხოვნა ერთი და იმავე წევრით ერთში გაერთიანდება - ამიტომ დაურთე შემთხვევითი სუფიქსი ან მოთხოვნის id.
ეს ზუსტია: საზღვარი, რომლის ბოროტად გამოყენებაც შეიძლება, არ არსებობს. ფასი ლიმიტის პროპორციული მეხსიერებაა. წუთში 10-იანი ლიმიტი კლიენტზე მაქსიმუმ 10 ჩანაწერს ინახავს; საათში 10,000-იანი ლიმიტი - 10,000-მდე. მგრძნობიარე მოქმედებებზე დაბალი ლიმიტებისთვის - შესვლები, პაროლის აღდგენა, ერთჯერადი კოდები - sliding log იდეალურია. დიდი მოცულობის API ლიმიტებისთვის ის ფლანგავს.
არის ერთი დახვეწილი არჩევანიც: ითვლება თუ არა უარყოფილი მოთხოვნა? ზემოთ მოცემულ თანმიმდევრობაში ითვლება, რადგან დათვლამდე დაემატა. ეს ნიშნავს, რომ კლიენტი, რომელიც endpoint-ს ურტყამს, დაბლოკილი რჩება, სანამ არ გაჩერდება, რაც brute force-ისთვის ჩვეულებრივ ისაა, რაც გინდა. თუ მხოლოდ მიღებული მოთხოვნების დათვლა გირჩევნია, ჯერ რაოდენობა შეამოწმე და მხოლოდ მაშინ დაამატე, თუ ლიმიტს ქვემოთაა - და მაშინ შემოწმება და დამატება ატომურად უნდა მოხდეს, რისთვისაც არის ქვემოთ Lua-ს სექცია.
Sliding window counter#
sliding window counter fixed window-ის ორ პატარა გასაღებს ინარჩუნებს და მოცურავ ფანჯარას წინა ფანჯრის შეწონვით აახლოებს.
დავუშვათ, ლიმიტი წუთში 100-ია, ახლა 14:13:15-ია, წინა წუთს ჰქონდა 80 მოთხოვნა, ამ წუთს კი ჯერჯერობით 30. მიმდინარე ფანჯარაში თხუთმეტი წამის შემდეგ წინა ფანჯრის 45 წამი ჯერ კიდევ ხვდება "ბოლო 60 წამში". შეფასება ასეთია:
estimate = current + previous * (60 - 15) / 60 = 30 + 80 * 0.75 = 90 (allowed, under 100)ის ვარაუდობს, რომ წინა ფანჯარაში მოთხოვნები თანაბრად იყო განაწილებული, ამიტომ ზუსტი არ არის, მაგრამ fixed window-ის ორმაგი ნახტომის პრობლემას აქრობს და ლიმიტის მიუხედავად ორ მთვლელს იყენებს. ბევრი დიდი API gateway სწორედ ამ მიზეზით იყენებს მას. Valkey-ს მხარეს ეს არის ორი GET და ერთი INCR ფანჯრის ორმაგი ვადით, იდეალურად ერთ სკრიპტში.
Token bucket ატომურ Lua სკრიპტში#
token bucket სტაბილურ სიჩქარეს ნახტომის მარაგით უშვებს. bucket-ი იტევს capacity-მდე token-ს და ივსება წამში rate token-ით; ყოველი მოთხოვნა ერთს იღებს. კლიენტს, რომელიც ჩუმად იყო, შეუძლია ტევადობამდე ნახტომი გააკეთოს, შემდეგ კი შევსების სიჩქარეზე იზღუდება. ეს API-ებისთვის ყველაზე ბუნებრივი მოდელია, და ის, რომელსაც ყველაზე აშკარად სჭირდება ატომურობა: წაიკითხე bucket-ი, გამოთვალე შევსება, აიღე token, ჩაწერე უკან, ყველაფერი ერთ ნაბიჯად.
Lua სკრიპტები Valkey-ში ატომურად სრულდება: სანამ სკრიპტი მუშაობს, სხვა ბრძანება არ სრულდება.
-- KEYS[1] bucket key; ARGV: capacity, refill per second, now in ms, costlocal capacity = tonumber(ARGV[1])local rate = tonumber(ARGV[2])local now = tonumber(ARGV[3])local cost = tonumber(ARGV[4])local state = redis.call("HMGET", KEYS[1], "tokens", "ts")local tokens = tonumber(state[1]) or capacitylocal ts = tonumber(state[2]) or nowlocal elapsed = math.max(0, now - ts) / 1000tokens = math.min(capacity, tokens + elapsed * rate)local allowed = 0if tokens >= cost then tokens = tokens - cost allowed = 1endredis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)redis.call("PEXPIRE", KEYS[1], math.ceil(capacity / rate * 1000) + 1000)local retry_ms = 0if allowed == 0 then retry_ms = math.ceil((cost - tokens) / rate * 1000) endreturn { allowed, math.floor(tokens), retry_ms }ჩატვირთე ერთხელ და გამოიძახე hash-ით:
$ valkey-cli --askpass -h 203.0.113.20 -p 6380 SCRIPT LOAD "$(cat token_bucket.lua)""3f1a..."$ valkey-cli --askpass -h 203.0.113.20 -p 6380 EVALSHA 3f1a... 1 rl:tb:key_abc 20 5 1791468000000 1კლიენტის ბიბლიოთეკების უმეტესობა ამას ფუთავს: ისინი EVALSHA-ს აგზავნიან და სრული სკრიპტით EVAL-ზე გადადიან, თუ სერვერი NOSCRIPT-ით პასუხობს, რაც restart-ის შემდეგ ხდება, რადგან ჩატვირთული სკრიპტები არ ინახება. Valkey სკრიპტებში server.call-საც იღებს, როგორც redis.call-ის ალიასს; აქ redis.call გამოიყენება, რადგან ის Valkey-ზეც მუშაობს და Redis-ზეც.
შენიშვნები სკრიპტზე:
- მიმდინარე დრო კლიენტიდან გადაეცემა. ეს სკრიპტს დეტერმინისტულს ტოვებს, მაგრამ ნიშნავს, რომ ყოველ აპლიკაციის სერვერს ზუსტი საათი სჭირდება. თუ შენი სერვერების საათები წამებით განსხვავდება, bucket-ები არასწორად ივსება. გაუშვი NTP, ან ამის ნაცვლად სკრიპტში გამოიძახე
TIME. - ვადა დაყენებულია იმ დროზე, რაც bucket-ის სრულ შევსებას სჭირდება, პლუს მარაგი, ამიტომ უმოქმედო კლიენტები არაფერი ჯდება.
- `retry_ms` ბრუნდება, რომ გამომძახებელმა
Retry-Afterheader გაგზავნოს.
სკრიპტის იგივე ფორმა - წაიკითხე მდგომარეობა, გადაწყვიტე, ჩაწერე მდგომარეობა - არის გზა ნებისმიერი limiter-ის ატომურად გასახდელად, მათ შორის "მხოლოდ მიღებული მოთხოვნების დათვლის" sliding log-ისაც.
limiter-ის ტესტირება, სანამ მას ენდობი
limiter უსაფრთხოების კოდია და იგივე ტესტებს იმსახურებს. სამის ავტომატიზაცია ღირს:
- პარალელურობა. რამდენიმე პროცესიდან პარალელურად გაუშვი ლიმიტის ორმაგი რაოდენობა და დათვალე, რამდენი გავიდა. თუ ლიმიტზე მეტი გადის, რაღაც ატომური არ არის - ჩვეულებრივ კითხვა და ჩაწერა ცალკეულ round trip-ებში.
- საზღვრები. ფანჯრებისთვის გაგზავნე სრული ლიმიტი ფანჯრის საზღვრამდე ცოტა ხნით ადრე და ისევ ცოტა ხნის შემდეგ. ზუსტად ის ქცევა უნდა დაინახო, რაც აირჩიე: fixed window ორივე ნახტომს უშვებს, sliding log - არცერთს.
- აღდგენა. ამოწურე ლიმიტი, დაელოდე გამოცხადებულ
Retry-After-ს და დაადასტურე, რომ შემდეგი მოთხოვნა დაშვებულია. ვადის არითმეტიკაში ერთით შეცდომა აქ ვლინდება კლიენტების სახით, რომლებიც ერთი ფანჯრით უფრო დიდხანს არიან დაბლოკილი, ვიდრე უთხარი.
ასევე გამოცადე მარცხის გზა: limiter მიმართე პორტზე, სადაც არაფერი უსმენს. მოთხოვნა უნდა დაიშვას ან უარყოფილ იქნას შენი fail-open ან fail-closed გადაწყვეტილების მიხედვით, და შენი timeout-ის ფარგლებში, არა ოცდაათი წამის შემდეგ. ეს ტესტები ნამდვილ Valkey-ზე გაუშვი და არა mock-ზე - აზრი სერვერის მიერ მოწოდებული ატომურობის გამოცდაა, რაც mock-ს არ აქვს.
გასაღების არჩევა: რას ზღუდავ სინამდვილეში#
ალგორითმი მარტივი ნაწილია. გასაღები წყვეტს, ლიმიტი ბოროტად გამოყენებას შეაჩერებს თუ კლიენტებს გააღიზიანებს.
- IP მისამართით ერთადერთი ვარიანტია ანონიმური ტრაფიკისთვის და ყველაზე სუსტი. reverse proxy-ის უკან ყოველი მოთხოვნა proxy-ის მისამართიდან მოდის, ამიტომ კლიენტის მისამართი
X-Forwarded-For-იდან უნდა წაიკითხო - და ამ header-ს მხოლოდ მაშინ ენდო, როცა ის შენმა proxy-მ დააყენა, თორემ ნებისმიერს შეუძლია ყალბი გაგზავნოს. ბევრი მომხმარებელი მისამართს იზიარებს (ოფისები, მობილური ოპერატორები CGNAT-ით), ამიტომ მისამართზე დაფუძნებული ლიმიტები გულუხვი უნდა იყოს. რას აკეთებს reverse proxy ამ header-ს ხსნის. - ანგარიშით ან API გასაღებით ზუსტი და სამართლიანია. გამოიყენე ის ყველაფრისთვის, რაც ავთენტიფიკაციის უკანაა.
- სამიზნით, შესვლებისთვის: შეზღუდე მცდელობები მომხმარებლის სახელზეც და მისამართზეც, რომ ერთ ანგარიშზე განაწილებული შეტევა დაიჭირო, მაშინაც კი, როცა თითოეული მისამართი მხოლოდ ერთხელ ცდის.
- მოქმედებით: ცალკე გასაღებები ცალკე ღირებულებებისთვის. ძებნის endpoint-მა და პროფილის წაკითხვამ ერთი ბიუჯეტი არ უნდა გაიზიარონ.
ყოველ გასაღებში ჩადე ვერსია და დანიშნულება - rl:v1:login:user:4182 - რომ ალგორითმის შეცვლა ახალი პრეფიქსი იყოს და არა მიგრაცია, და რომ --scan --pattern 'rl:*'-მა ყველა იპოვოს.
Fail open თუ closed, და კლიენტისთვის შეტყობინება#
გადაწყვიტე, რა ხდება, როცა Valkey მიუწვდომელია, რადგან ეს ადრე თუ გვიან მოხდება.
- Fail open (მოთხოვნის დაშვება) ზოგადი API ლიმიტებისთვის. rate limiter, რომელიც მიუწვდომლობისას მთელ საიტს თიშავს, დაცვას გათიშვად აქცევს.
- Fail closed (უარყოფა) ლიმიტებისთვის, რომლებიც რაღაც ღირებულს იცავს: შესვლის მცდელობები, პაროლის აღდგენა, ერთჯერადი კოდის შემოწმება, ძვირი ფასიანი API გამოძახებები.
ნებისმიერ შემთხვევაში დააყენე კლიენტის მოკლე timeout - მილიწამების ათეულები - რომ ნელმა Valkey-მ ყოველ მოთხოვნას წამები არ დაუმატოს.
როცა უარს ამბობ, დააბრუნე HTTP 429 Too Many Requests Retry-After header-ით წამებში. კარგად აწყობილი კლიენტები და SDK-ები ამის დანახვისას ავტომატურად იხევენ უკან. ბევრი API ყოველ პასუხზე დარჩენილ ბიუჯეტსაც აგზავნის, ჩვეულებრივ X-RateLimit-Limit-ის, X-RateLimit-Remaining-ისა და X-RateLimit-Reset-ის სახით, ან სტანდარტიზებული RateLimit header-ებით, რომლებიც IETF-ში მუშავდება. რაც არ უნდა აირჩიო, დაადოკუმენტირე.
ბიბლიოთეკები, რომლებიც ამას უკვე აკეთებენ#
limiter-ის დაწერა სასწავლოა. საკუთარი დაწერილის production-ში გაშვება ჩვეულებრივ ზედმეტია.
| Runtime | ბიბლიოთეკა | შენიშვნები |
|---|---|---|
| Node.js | rate-limiter-flexible | fixed window და token-bucket სტილის limiter-ები, Redis-ის პროტოკოლის საცავი |
| Node.js | express-rate-limit rate-limit-redis-ით | Express middleware გაზიარებული საცავით |
| Python | limits, რომელსაც Flask-Limiter და slowapi იყენებენ | ფიქსირებული და მოძრავი ფანჯრები Redis-ის პროტოკოლის backend-ზე |
| Django | django-ratelimit | იყენებს დაკონფიგურირებულ ქეშს, ამიტომ ქეში Valkey-ზე მიმართე |
| Laravel | ჩაშენებული RateLimiter და throttle middleware | იყენებს ქეშის საცავს; ქეშის driver დააყენე redis-ზე |
| ASP.NET Core | ჩაშენებული rate limiting middleware | მეხსიერებაში, თითო პროცესზე; გაზიარებულ საცავს მესამე მხარის პაკეტი სჭირდება |
ASP.NET Core-ის სტრიქონი მახეა: AddRateLimiter .NET 7-დან მოყოლებული თითო პროცესზე მუშაობს, ამიტომ ორი ინსტანცია თითოეული სრულ ლიმიტს ახორციელებს. ეს კარგია ერთი ინსტანციის გადატვირთვისგან დასაცავად და არასწორია კლიენტის კვოტის აღსასრულებლად.
Valkey-ს მეხსიერებისა და შენახვის პარამეტრებს აქ ნაკლები მნიშვნელობა აქვს, ვიდრე სესიებისა და რიგებისთვის. rate-limit-ის გასაღებები პაწაწინა და ხანმოკლეა; restart-ზე მათი დაკარგვა უბრალოდ ყველას მთვლელებს ანულებს. განდევნის პოლიტიკა მისაღებია Valkey-სთვის, რომელიც მხოლოდ ლიმიტებს ინახავს. თუ იგივე ინსტანცია სესიებს ან ამოცანებს ინახავს, არჩევამდე იხილე Valkey-ს მეხსიერება და eviction პოლიტიკები.
FAQ#
რომელი ალგორითმი გამოვიყენო?
sliding log დაბალი ლიმიტებისთვის მგრძნობიარე მოქმედებებზე, როგორიცაა შესვლა, რადგან ის ზუსტია. token bucket API-ებისთვის, რადგან გონივრულ ნახტომებს უშვებს და სტაბილურ სიჩქარეს ინარჩუნებს. fixed window, როცა რამე დღესვე გჭირდება და საზღვარზე ნახტომებს მნიშვნელობა არ აქვს. sliding window counter კარგი ზოგადი ნაგულისხმევია, როცა კლიენტზე მეხსიერებას მნიშვნელობა აქვს.
საკმარისია MULTI/EXEC ტრანზაქცია Lua-ს ნაცვლად?
fixed window-ებისთვის - კი: INCR და EXPIRE ერთმანეთის შედეგებზე არ არის დამოკიდებული. ყველაფრისთვის, რაც მნიშვნელობას კითხულობს და წყვეტს, რა ჩაწეროს - არა: MULTI ბრძანებებს რიგში აყენებს, მაგრამ მათ მიერ დაბრუნებულის მიხედვით განშტოება არ შეუძლია. ამისთვის არის Lua სკრიპტი.
რამდენ მეხსიერებას იყენებს rate-limit-ის გასაღებები?
ძალიან ცოტას. fixed-window-ის მთვლელი ან token-bucket-ის hash ასი ბაიტის კარგა ქვემოთაა, პლუს გასაღების ზედნადები, და ვადა თავისით გასდის. ასი ათასი აქტიური კლიენტი რამდენიმე ათეულ მეგაბაიტში ეტევა. sliding log-ები გამონაკლისია: ისინი ფანჯარაში ყოველ მოთხოვნაზე ერთ ჩანაწერს ინახავენ.
შემიძლია IP-ით შეზღუდვა Cloudflare-ის ან სხვა proxy-ის უკან?
კი, კლიენტის იმ მისამართით, რომელსაც proxy გადმოგზავნის, და მხოლოდ მას შემდეგ, რაც დაადასტურებ, რომ header შენი proxy-დან მოვიდა. მისამართი წაიკითხე იმ header-იდან, რომელსაც შენი proxy-ის დოკუმენტაცია ასახელებს, და უგულებელყავი ის მოთხოვნებზე, რომლებიც proxy-ის გავლით არ მოსულა.
Valkey-ს restart ყველა ლიმიტს ანულებს?
შენახვის გარეშე - კი. ჩართული AOF-ით მთვლელები restart-ს გადაურჩება და მატებების მაქსიმუმ დაახლოებით ერთ წამს კარგავს. rate limit-ებისთვის ორივე შემთხვევა ჩვეულებრივ მისაღებია; ბიზნეს მნიშვნელობის დღიური კვოტებისთვის შენახვა ჩართული დატოვე.




კომენტარები
სრულიად ანონიმურად: ანგარიშის, ელფოსტის და cookie-ის გარეშე. ინახება მხოლოდ სახელი, ტექსტი და დრო - სხვა არაფერი. ბმულების რაოდენობა ლიმიტირებულია.