├── .gitignore ├── .luacheckrc ├── .luacov ├── .travis.yml ├── Dockerfile ├── Dockerfile.test ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yml ├── img └── graph.png ├── nginx.conf ├── resty-redis-rate-1.0.0-0.rockspec ├── spec └── resty-redis-rate_spec.lua └── src └── resty-redis-rate.lua /.gitignore: -------------------------------------------------------------------------------- 1 | luacov.* 2 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | std="ngx_lua+busted" 2 | max_line_length=120 3 | 4 | -------------------------------------------------------------------------------- /.luacov: -------------------------------------------------------------------------------- 1 | return { 2 | ["include"] = { 'src/' } 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: lua 2 | 3 | services: 4 | - docker 5 | dist: trusty 6 | 7 | env: 8 | COMPOSE_VERSION: 18.06 9 | 10 | install: docker-compose build test 11 | 12 | script: 13 | - docker-compose run --rm lint 14 | - docker-compose run --rm test 15 | 16 | after_success: docker-compose run --rm coverage 17 | 18 | after_script: docker-compose down 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openresty/openresty:xenial 2 | 3 | RUN apt-get update \ 4 | && apt-get install -y \ 5 | git \ 6 | && mkdir /src \ 7 | && cd /src \ 8 | && git config --global url."https://".insteadOf git:// \ 9 | && luarocks install lua-resty-redis \ 10 | && luarocks install lua-resty-lock \ 11 | && git clone https://github.com/steve0511/resty-redis-cluster.git \ 12 | && cd resty-redis-cluster/ \ 13 | && git checkout 8d7b96d002337c38d71859e5f04f76b413aa5c29 \ 14 | && luarocks make \ 15 | && gcc -fPIC -shared -I/usr/local/openresty/luajit -o /usr/local/openresty/luajit/lib/lua/5.1/librestyredisslot.so src/redis_slot.c \ 16 | && rm -Rf /src 17 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM openresty/openresty:xenial 2 | 3 | RUN apt-get update \ 4 | && apt-get install -y \ 5 | git \ 6 | && mkdir /src \ 7 | && cd /src \ 8 | && git config --global url."https://".insteadOf git:// \ 9 | && luarocks install lua-resty-redis \ 10 | && luarocks install lua-resty-lock \ 11 | && luarocks install luasocket \ 12 | && luarocks install luacheck \ 13 | && luarocks install luacov \ 14 | && luarocks install luacov-coveralls \ 15 | && luarocks install busted \ 16 | && luarocks install lua-resty-perf 1.0.4-0 \ 17 | && git clone https://github.com/steve0511/resty-redis-cluster.git \ 18 | && cd resty-redis-cluster/ \ 19 | && git checkout 8d7b96d002337c38d71859e5f04f76b413aa5c29 \ 20 | && luarocks make \ 21 | && gcc -fPIC -shared -I/usr/local/openresty/luajit -o /usr/local/openresty/luajit/lib/lua/5.1/librestyredisslot.so src/redis_slot.c \ 22 | && rm -Rf /src 23 | 24 | CMD ["busted"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Leandro Moreira 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | docker-compose build nginx 3 | 4 | up: down 5 | docker-compose up nginx 6 | 7 | down: 8 | docker-compose down -v 9 | 10 | build-test: 11 | docker-compose build test 12 | 13 | test: 14 | docker-compose run --rm test 15 | 16 | lint: 17 | docker-compose run --rm lint 18 | 19 | .PHONY: build up down build-test test 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/leandromoreira/nginx-lua-redis-rate-measuring.svg?branch=master)](https://travis-ci.org/leandromoreira/nginx-lua-redis-rate-measuring) [![license](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg) [![Coverage Status](https://coveralls.io/repos/github/leandromoreira/nginx-lua-redis-rate-measuring/badge.svg)](https://coveralls.io/github/leandromoreira/nginx-lua-redis-rate-measuring) 2 | 3 | # Resty Redis Rate 4 | 5 | A [Lua](https://www.lua.org/) library providing rate measurement using [nginx](https://nginx.org/) + Redis. This lib was inspired on Cloudflare's post [How we built rate limiting capable of scaling to millions of domains.](https://blog.cloudflare.com/counting-things-a-lot-of-different-things/) 6 | 7 | > You can found more about why and when this library was created [here.](https://leandromoreira.com.br/2019/01/25/how-to-build-a-distributed-throttling-system-with-nginx-lua-redis/). 8 | 9 | # Use case: distributed throttling 10 | 11 | Nginx has already [a rate limiting feature](https://www.nginx.com/blog/rate-limiting-nginx/) but it is restricted by the local node. Once you have more than one server behind a load balancer this won't work as expected, so you can use [redis](https://redis.io/) as a distributed storage to keep the rating data. 12 | 13 | ```lua 14 | local redis_client = redis_cluster:new(config) 15 | -- let's say we'll use the ?token= as the key to rate limit 16 | local rate, err = redis_rate.measure(redis_client, ngx.var.arg_token) 17 | if err then 18 | ngx.log(ngx.ERR, "err: ", err) 19 | ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) 20 | end 21 | 22 | -- once we hit more than 10 reqs/m we'll reply 403 23 | if rate > 10 then 24 | ngx.exit(ngx.HTTP_FORBIDDEN) 25 | end 26 | 27 | ngx.say(rate) 28 | ``` 29 | 30 | ### Tests result 31 | 32 | We ran three different experiments constrained by a `rate limit of 10 req/minute`: 33 | 34 | 1. `Experiment1:` 1 reqs/second 35 | 1. `Experiment2:` 1/6 reqs/second 36 | 1. `Experiment3:` 1/5 reqs/second 37 | 38 | ![nginx redis throttling exprimentes graph result](/img/graph.png "A graph with experiments results") 39 | 40 | > All the data points above the rate limit (the red line) resulted in forbidden responses. 41 | 42 | You can run the throttling example locally, open up a terminal tab to run the servers. 43 | 44 | > **Make sure you have `docker` and `docker-compose` installed.** 45 | 46 | ```bash 47 | make up 48 | ``` 49 | Open another terminal tab and perform the experiments: 50 | 51 | ```bash 52 | # Experiment 1 53 | for i in {1..120}; do curl "http://localhost:8080/lua_content?token=Experiment1" && sleep 1; done 54 | 55 | # Experiment 2 56 | for i in {1..20}; do curl "http://localhost:8080/lua_content?token=Experiment2" && sleep 6; done 57 | 58 | # Experiment 3 59 | for i in {1..24}; do curl "http://localhost:8080/lua_content?token=Experiment3" && sleep 5; done 60 | ``` 61 | 62 | # Pipeline and hash tag 63 | 64 | We're using the combination of [pipeline](https://redis.io/topics/pipelining) and [hash tag](https://redis.io/topics/cluster-spec#keys-hash-tags) to perform all the commands in a single connection to redis cluster. You can see the tcpdump output showing the [three-way handshake](https://en.wikipedia.org/wiki/Handshaking#TCP_three-way_handshake) followed by the three commands requests `$get`, `$inc` and `$expire` and the redis response. 65 | 66 | ```bash 67 | 22:20:10.515457 IP (tos 0x0, ttl 64, id 38199, offset 0, flags [DF], proto TCP (6), length 60) 68 | 172.31.0.3.49824 > 172.31.0.2.7000: Flags [S], cksum 0x5872 (incorrect -> 0xb9b9), seq 1010830934, win 29200, options [mss 1460,sackOK,TS val 170849 ecr 0,nop,wscale 7], length 0 69 | 70 | 22:20:10.515505 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60) 71 | 172.31.0.2.7000 > 172.31.0.3.49824: Flags [S.], cksum 0x5872 (incorrect -> 0xfcda), seq 1496303914, ack 1010830935, win 28960, options [mss 1460,sackOK,TS val 170849 ecr 170849,nop,wscale 7], length 0 72 | 73 | 22:20:10.515518 IP (tos 0x0, ttl 64, id 38200, offset 0, flags [DF], proto TCP (6), length 52) 74 | 172.31.0.3.49824 > 172.31.0.2.7000: Flags [.], cksum 0x586a (incorrect -> 0x9be2), seq 1, ack 1, win 229, options [nop,nop,TS val 170849 ecr 170849], length 0 75 | 76 | 22:20:10.515648 IP (tos 0x0, ttl 64, id 38201, offset 0, flags [DF], proto TCP (6), length 212) 77 | 172.31.0.3.49824 > 172.31.0.2.7000: Flags [P.], cksum 0x590a (incorrect -> 0x7954), seq 1:161, ack 1, win 229, options [nop,nop,TS val 170849 ecr 170849], length 160 78 | 0x0000: 4500 00d4 9539 4000 4006 4ca7 ac1f 0003 E....9@.@.L..... 79 | 0x0010: ac1f 0002 c2a0 1b58 3c40 0e57 592f c92b .......X<@.WY/.+ 80 | 0x0020: 8018 00e5 590a 0000 0101 080a 0002 9b61 ....Y..........a 81 | 0x0030: 0002 9b61 2a32 0d0a 2433 0d0a 6765 740d ...a*2..$3..get. 82 | 0x0040: 0a24 3239 0d0a 6e67 785f 7261 7465 5f6d .$29..ngx_rate_m 83 | 0x0050: 6561 7375 7269 6e67 5f7b 6c75 6967 697d easuring_{luigi} 84 | 0x0060: 5f31 390d 0a2a 320d 0a24 340d 0a69 6e63 _19..*2..$4..inc 85 | 0x0070: 720d 0a24 3239 0d0a 6e67 785f 7261 7465 r..$29..ngx_rate 86 | 0x0080: 5f6d 6561 7375 7269 6e67 5f7b 6c75 6967 _measuring_{luig 87 | 0x0090: 697d 5f32 300d 0a2a 330d 0a24 360d 0a65 i}_20..*3..$6..e 88 | 0x00a0: 7870 6972 650d 0a24 3239 0d0a 6e67 785f xpire..$29..ngx_ 89 | 0x00b0: 7261 7465 5f6d 6561 7375 7269 6e67 5f7b rate_measuring_{ 90 | 0x00c0: 6c75 6967 697d 5f32 300d 0a24 330d 0a31 luigi}_20..$3..1 91 | 0x00d0: 3230 0d0a 20.. 92 | 22:20:10.517337 IP (tos 0x0, ttl 64, id 21067, offset 0, flags [DF], proto TCP (6), length 65) 93 | 172.31.0.2.7000 > 172.31.0.3.49824: Flags [P.], cksum 0x5877 (incorrect -> 0xc55e), seq 1:14, ack 161, win 235, options [nop,nop,TS val 170849 ecr 170849], length 13 94 | 0x0000: 4500 0041 524b 4000 4006 9028 ac1f 0002 E..ARK@.@..(.... 95 | 0x0010: ac1f 0003 1b58 c2a0 592f c92b 3c40 0ef7 .....X..Y/.+<@.. 96 | 0x0020: 8018 00eb 5877 0000 0101 080a 0002 9b61 ....Xw.........a 97 | 0x0030: 0002 9b61 242d 310d 0a3a 310d 0a3a 310d ...a$-1..:1..:1. 98 | 0x0040: 0a . 99 | ``` 100 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | 3 | services: 4 | nginx: 5 | build: 6 | context: . 7 | volumes: 8 | - "./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf" 9 | - "./src/:/lua/src/" 10 | depends_on: 11 | - redis_cluster 12 | links: 13 | - redis_cluster 14 | ports: 15 | - "8080:8080" 16 | 17 | test: 18 | command: busted -c 19 | environment: 20 | - TRAVIS=true 21 | - CI=true 22 | - COVERALLS_REPO_TOKEN=${COVERALLS_REPO_TOKEN} 23 | - TRAVIS_JOB_ID=${TRAVIS_JOB_ID} 24 | - TRAVIS_BRANCH=${TRAVIS_BRANCH} 25 | - TRAVIS_REPO_SLUG=${TRAVIS_REPO_SLUG} 26 | build: 27 | context: . 28 | dockerfile: Dockerfile.test 29 | volumes: 30 | - ".:/lua/" 31 | working_dir: "/lua" 32 | 33 | coverage: 34 | command: luacov-coveralls -v 35 | environment: 36 | - TRAVIS=true 37 | - CI=true 38 | - COVERALLS_REPO_TOKEN=${COVERALLS_REPO_TOKEN} 39 | - TRAVIS_JOB_ID=${TRAVIS_JOB_ID} 40 | - TRAVIS_BRANCH=${TRAVIS_BRANCH} 41 | - TRAVIS_REPO_SLUG=${TRAVIS_REPO_SLUG} 42 | build: 43 | context: . 44 | dockerfile: Dockerfile.test 45 | volumes: 46 | - ".:/lua/" 47 | working_dir: "/lua" 48 | 49 | lint: 50 | command: bash -c "luacheck -q ." 51 | environment: 52 | - TRAVIS=true 53 | - CI=true 54 | - COVERALLS_REPO_TOKEN=${COVERALLS_REPO_TOKEN} 55 | - TRAVIS_JOB_ID=${TRAVIS_JOB_ID} 56 | - TRAVIS_BRANCH=${TRAVIS_BRANCH} 57 | - TRAVIS_REPO_SLUG=${TRAVIS_REPO_SLUG} 58 | build: 59 | context: . 60 | dockerfile: Dockerfile.test 61 | volumes: 62 | - ".:/lua/" 63 | working_dir: "/lua" 64 | 65 | redis_cluster: 66 | image: grokzen/redis-cluster:latest 67 | ports: 68 | - '7000-7005:7000-7005' 69 | -------------------------------------------------------------------------------- /img/graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leandromoreira/nginx-lua-redis-rate-measuring/838a2812976b375325279ad21bc0201f3815e47d/img/graph.png -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | error_log stderr; 6 | 7 | http { 8 | resolver 127.0.0.11 ipv6=off; 9 | 10 | lua_package_path "/usr/local/openresty/lualib/?.lua;/usr/local/openresty/luajit/share/lua/5.1/?.lua;/lua/src/?.lua"; 11 | lua_package_cpath "/usr/local/openresty/lualib/?.so;/usr/local/openresty/luajit/lib/lua/5.1/?.so;"; 12 | 13 | lua_shared_dict redis_cluster_slot_locks 100k; 14 | 15 | init_by_lua_block { 16 | config = { 17 | name = "redis-cluster", 18 | serv_list = { 19 | { ip = "redis_cluster", port = 7000 }, 20 | }, 21 | keepalive_timeout = 60000, 22 | keepalive_cons = 1000, 23 | connection_timout = 1000, 24 | max_redirection = 5, 25 | } 26 | 27 | redis_cluster = require "resty-redis-cluster" 28 | redis_rate = require "resty-redis-rate" 29 | } 30 | 31 | server { 32 | listen 8080; 33 | 34 | location /lua_content { 35 | default_type 'text/plain'; 36 | # you probably want to use the https://github.com/openresty/lua-nginx-module#access_by_lua phase 37 | content_by_lua_block { 38 | local redis_client = redis_cluster:new(config) 39 | local rate, err = redis_rate.measure(redis_client, ngx.var.arg_token) 40 | if err then 41 | ngx.log(ngx.ERR, "err: ", err) 42 | ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) 43 | end 44 | 45 | if rate > 10 then 46 | ngx.exit(ngx.HTTP_FORBIDDEN) 47 | end 48 | 49 | ngx.say(rate) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /resty-redis-rate-1.0.0-0.rockspec: -------------------------------------------------------------------------------- 1 | package = "resty-redis-rate" 2 | version = "1.0.0-0" 3 | source = { 4 | url = "github.com/leandromoreira/nginx-lua-redis-rate-measuring", 5 | tag = "1.0.0" 6 | } 7 | description = { 8 | summary = "A resty Lua library to provide distributed rate measurement using Redis", 9 | homepage = "https://github.com/leandromoreira/nginx-lua-redis-rate-measuring", 10 | license = "BSD 3-Clause" 11 | } 12 | dependencies = { 13 | "lua-resty-redis", 14 | "lua-resty-lock" 15 | -- it also depends on resty-redis-cluster 16 | -- please see https://github.com/leandromoreira/nginx-lua-redis-rate-measuring/blob/master/Dockerfile 17 | } 18 | build = { 19 | type = "builtin", 20 | modules = { 21 | ["resty-redis-rate"] = "src/resty-redis-rate.lua" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /spec/resty-redis-rate_spec.lua: -------------------------------------------------------------------------------- 1 | package.path = package.path .. ";spec/?.lua" 2 | -- Thu Jan 31 10:50:48 -02 2019 3 | local FIXED_NOW = 1548939048 4 | local ngx_now = FIXED_NOW 5 | _G.ngx = { 6 | now=function() 7 | return ngx_now 8 | end, 9 | null="NULL" 10 | } 11 | 12 | local redis_rate = require "resty-redis-rate" 13 | local perf = require "resty.perf" 14 | 15 | local key_prefix = "ngx_rate_measuring" 16 | local fake_redis 17 | local expire_resp = "OK" 18 | local get_resp = "0" 19 | local incr_resp = "1" 20 | 21 | before_each(function() 22 | fake_redis = {} 23 | stub(fake_redis, "init_pipeline") 24 | stub(fake_redis, "get") 25 | stub(fake_redis, "incr") 26 | stub(fake_redis, "expire") 27 | fake_redis.commit_pipeline = function(_) 28 | return {get_resp, incr_resp, expire_resp}, nil 29 | end 30 | ngx_now = FIXED_NOW 31 | expire_resp = "OK" 32 | get_resp = "0" 33 | incr_resp = "1" 34 | end) 35 | 36 | describe("Resty Redis Rate", function() 37 | it("returns the rate", function() 38 | -- Thu Jan 31 10:50:48 -02 2019 39 | get_resp = "10" -- last minute rate was 10 (1 each 6 seconds) 40 | incr_resp = "5" -- current rate counter is 5 41 | 42 | local resp, err = redis_rate.measure(fake_redis, "key") 43 | 44 | assert.is_nil(err) 45 | -- it takes partial contribution from the first counter (12/60)*10 plus current counter 4 46 | assert.same(6, resp) 47 | end) 48 | 49 | describe("When there is no past counter", function() 50 | it("returns rate for ongoing current counter", function() 51 | get_resp = ngx.null 52 | incr_resp = "10" -- this is your 10th hit but your rate is 9 53 | 54 | local resp, err = redis_rate.measure(fake_redis, "key") 55 | 56 | assert.is_nil(err) 57 | assert.same(9, resp) 58 | end) 59 | 60 | it("returns rate for starting current counter", function() 61 | get_resp = ngx.null 62 | incr_resp = "1" -- this is your first hit but your rate is 0 63 | 64 | local resp, err = redis_rate.measure(fake_redis, "key") 65 | 66 | assert.is_nil(err) 67 | assert.same(0, resp) 68 | end) 69 | end) 70 | 71 | it("returns an error when redis unavailable", function() 72 | fake_redis.commit_pipeline = function(_) 73 | return nil, "error" 74 | end 75 | 76 | local resp, err = redis_rate.measure(fake_redis, "key") 77 | 78 | assert.is_nil(resp) 79 | assert.is_not_nil(err) 80 | end) 81 | 82 | describe("Expiration time", function() 83 | it("decreases ttl based on time has passed", function() 84 | -- ngx.now() is Thu Jan 31 10:50:48 -02 2019 (1548939048) 85 | -- current second being 48 so we just subtract 48 seconds from 2 minutes 86 | local _ = redis_rate.measure(fake_redis, "key") 87 | 88 | assert.stub(fake_redis.expire).was_called_with(fake_redis, key_prefix .. "_{key}_50", 2 * 60 - 48) 89 | 90 | -- now we're simulating a second call after 10 seconds 91 | ngx_now = 1548939058 92 | 93 | _ = redis_rate.measure(fake_redis, "key") 94 | 95 | assert.stub(fake_redis.expire).was_called_with(fake_redis, key_prefix .. "_{key}_50", 2 * 60 - 58) 96 | end) 97 | 98 | it("works for the first second", function() 99 | -- $ date -r 1548939600 100 | -- Thu Jan 31 11:00:00 -02 2019 101 | ngx_now = 1548939600 102 | local _ = redis_rate.measure(fake_redis, "key") 103 | 104 | assert.stub(fake_redis.expire).was_called_with(fake_redis, key_prefix .. "_{key}_0", 2 * 60) 105 | end) 106 | 107 | it("works for the last second", function() 108 | -- $ date -r 1548939659 109 | -- Thu Jan 31 11:00:59 -02 2019 110 | ngx_now = 1548939659 111 | local _ = redis_rate.measure(fake_redis, "key") 112 | 113 | assert.stub(fake_redis.expire).was_called_with(fake_redis, key_prefix .. "_{key}_0", 2 * 60 - 59) 114 | end) 115 | end) 116 | 117 | it("works when minute wraps around", function() 118 | -- $ date -r 1548939600 119 | -- Thu Jan 31 11:00:00 -02 2019 120 | ngx_now = 1548939600 121 | 122 | local _ = redis_rate.measure(fake_redis, "key") 123 | 124 | assert.stub(fake_redis.get).was_called_with(fake_redis, key_prefix .. "_{key}_59") 125 | end) 126 | end) 127 | 128 | describe("Resty Redis Rate", function() 129 | it("runs memory and throughput profiling", function() 130 | -- Thu Jan 31 10:50:48 -02 2019 131 | get_resp = "10" -- last minute rate was 10 (1 each 6 seconds) 132 | incr_resp = "5" -- current rate counter is 5 133 | 134 | local resp, err = redis_rate.measure(fake_redis, "key") 135 | local fn_bench = function() 136 | resp, err = redis_rate.measure(fake_redis, "key") 137 | end 138 | 139 | local cpu_bench_out = function(result) 140 | print("\nCPU: #measures runs at " .. result .. " seconds") 141 | end 142 | 143 | local mem_bench_out = function(result) 144 | print("\nMEM: #measures uses " .. result .. " kb") 145 | end 146 | 147 | perf.perf_time("rate#measure", fn_bench, cpu_bench_out, {N=1e3, now=function()return os.clock()end}) 148 | perf.perf_mem("rate#measure", fn_bench, mem_bench_out, {N=1e3}) 149 | 150 | assert.is_nil(err) 151 | -- it takes partial contribution from the first counter (12/60)*10 plus current counter 4 152 | assert.same(6, resp) 153 | end) 154 | end) 155 | -------------------------------------------------------------------------------- /src/resty-redis-rate.lua: -------------------------------------------------------------------------------- 1 | local redis_rate = {} 2 | 3 | local key_prefix = "ngx_rate_measuring" 4 | local math_floor = math.floor 5 | local ngx_now = ngx.now 6 | local ngx_null = ngx.null 7 | local tonumber = tonumber 8 | 9 | redis_rate.measure = function(redis_client, key) 10 | local current_time = math_floor(ngx_now()) 11 | local current_second = current_time % 60 12 | local current_minute = math_floor(current_time / 60) % 60 13 | local past_minute = (current_minute + 59) % 60 14 | local current_key = key_prefix .. "_{" .. key .. "}_" .. current_minute 15 | local past_key = key_prefix .. "_{" .. key .. "}_" .. past_minute 16 | 17 | redis_client:init_pipeline() 18 | 19 | redis_client:get(past_key) 20 | redis_client:incr(current_key) 21 | redis_client:expire(current_key, 2 * 60 - current_second) 22 | 23 | local resp, err = redis_client:commit_pipeline() 24 | if err then 25 | return nil, err 26 | end 27 | 28 | local first_resp = resp[1] 29 | if first_resp == ngx_null then 30 | first_resp = "0" 31 | end 32 | local past_counter = tonumber(first_resp) 33 | local current_counter = tonumber(resp[2]) - 1 34 | 35 | -- strongly inspired by https://blog.cloudflare.com/counting-things-a-lot-of-different-things/ 36 | local current_rate = past_counter * ((60 - (current_time % 60)) / 60) + current_counter 37 | return current_rate, nil 38 | end 39 | 40 | return redis_rate 41 | --------------------------------------------------------------------------------