1. Redis 中使用 Lua 腳本
Redis 本身已經提供了豐富的命令,但是直接用來處理一些復雜業務時可能還不夠方便,會有一定的局限性。因此,在 Redis2.6 版本開始提供了對 Lua 腳本的支持,Lua 腳本的使用還是比較廣泛的,比如商品秒殺、分布式鎖等,使用 Lua 腳本可以帶來以下的好處:
為了讓例子更加的貼近實際應用,這里實現一個簡單版的分布式鎖。這里先用 Jedis 操作。
上邊詳細的介紹了分布式鎖的實現過程,以及可能出現的問題,最終,我們決定刪除鎖的操作使用 Lua 腳本實現,對應的腳本如下:
Lua 腳本中執行具體的 Redis 命令,需要使用 redis.call() 方法, KEYS 表示客戶端發起腳本執行命令時攜帶的 Redis key 的一個集合, ARGV 則是其它參數的一個集合,主意下標從1開始。結合我們的業務,這里的 KEYS[1] 則表示 lock , ARGV[1] 則是一個隨機字元串。整個腳本的含義就是,如果客戶端傳遞的 lock 的 value 和 Redis 中存儲的一致,就刪除 lock 。
Lua 腳本的語法還是比較簡單的,具體內容可以自行學習。
前邊的准備工作基本結束了,文章開始說過執行腳本有兩種途徑,下邊我們具體來看:
這里使用 jedis.eval() 發送腳本到 Redis 伺服器執行,後兩個參數分別是 key 的集合,以及 value 參數的集合。
先將腳本以文件形式放到 Redis 里,例如這樣:
然後通過如下命令讓 Redis 伺服器緩存腳本:
script load 命令會在 Redis 伺服器緩存 Lua 腳本,並且腳本內容經過 SHA-1 簽名演算法處理後,會返回腳本內容的 SHA1 校驗和的編碼,然後在端調用時,傳入編碼字元串作為參數,這樣 Redis 伺服器就會執行對應緩存的腳本了,就不用了每次發送具體的腳本內容了。
還有兩個比較有用的命令:
除了使用上邊的命令緩存腳本、生成腳本的 SHA1 校驗和的編碼,還可以使用 Jedis 實現,但最終的 SHA1 編碼內容是不同的:
實際的項目中,可能更多的會在 SpringBoot 項目中整合 Redis,此時執行 Lua 腳本的基本流程如下:
核心的類就是 DefaultRedisScript ,它實現了 RedisScript 介面。 execute() 方法最後一個參數是可變類型的,用來傳遞多個 value 參數。初次執行 execute() 方法時,其內部會自動緩存 Lua 腳本到 Redis 伺服器;同時每次執行腳本時會根據腳本內容自動計算出對應的 SHA1 校驗和的編碼,去匹配、執行緩存的腳本。
具體的 SHA1 校驗和的編碼,可以在 execute() 方法執行後,使用 redisScript.getSha1() 查看。使用 SpringBoot 方式 執行 Lua 腳本生成的 SHA1 校驗和的編碼和前邊直接使用 Jedis 生成的一致。
無論用那種方式在 Redis 中使用 Lua 腳本,其中的原理都是類似的。
2. 何時部署要啟動lua nginx
Lua是一個可以嵌入到Nginx配置文件中的動態腳本語言,從而可以在Nginx請求處理的任何階段執行各種Lua代碼。剛開始我們只是用Lua 把請求路由到後端伺服器,但是它對我們架構的作用超出了我們的預期。下面就講講我們所做的工作。
強制搜索引擎只索引mixlr.com
Google把子域名當作完全獨立的網站,我們不希望爬蟲抓取子域名的頁面,降低我們的Page rank
location /robots.txt {
rewrite_by_lua '
if ngx.var.http_host ~= "mixlr.com" then
return ngx.exec("/robots_disallow.txt");
end
';
}
如果對robots.txt的請求不是mixlr.com域名的話,則內部重寫到robots_diallow.txt,雖然標準的重寫指令也可以實現這個需求,但是 Lua的實現更容易理解和維護。
根據程序邏輯設置響應頭
Lua提供了比Nginx默認配置規則更加靈活的設置方式。 在下面的例子中,我們要保證正確設置響應頭,這樣瀏覽器如果發送了指定請求頭後,就可以 無限期緩存靜態文件,是的用戶只需下載一次即可。
這個重寫規則使得任何靜態文件,如果請求參數中包含時間戳值,那麼就設置相應的Expires和Cache-Control響應頭。
location / {
header_filter_by_lua '
if ngx.var.query_string and ngx.re.match( ngx.var.query_string, "^([0-9]{10})$" ) then
ngx.header["Expires"] = ngx.http_time( ngx.time() + 31536000 );
ngx.header["Cache-Control"] = "max-age=31536000";
end
';
try_files $uri @dynamic;}
刪除jQuery JSONP請求的時間戳參數
很多外部客戶端請求JSONP介面時,都會包含一個時間戳類似的參數,從而導致Nginx proxy緩存無法命中(因為無法忽略指定的HTTP參數)。下面的 規則刪除了時間戳參數,使得Nginx可以緩存upstream server的響應內容,減輕後端伺服器的負載。
location / {
rewrite_by_lua '
if ngx.var.args ~= nil then
-- /some_request?_=1346491660 becomes /some_request
local fixed_args, count = ngx.re.sub( ngx.var.args, "&?_=[0-9]+", "" );
if count > 0 then
return ngx.exec(ngx.var.uri, fixed_args);
end
end
';}
把後端的慢請求日誌記錄到Nginx的錯誤日誌
如果後端請求響應很慢,可以把它記錄到Nginx的錯誤日誌,以備後續追查。
location / {
log_by_lua '
if tonumber(ngx.var.upstream_response_time) >= 1 then
ngx.log(ngx.WARN, "[SLOW] Ngx upstream response time: " .. ngx.var.upstream_response_time .. "s from " .. ngx.var.upstream_addr);
end
';}
基於Redis的實時IP封禁
某些情況下,需要阻止流氓爬蟲的抓取,這可以通過專門的封禁設備去做,但是通過Lua,也可以實現簡單版本的封禁。
lua_shared_dict banned_ips 1m;
location / {
access_by_lua '
local banned_ips = ngx.shared.banned_ips;
local updated_at = banned_ips:get("updated_at");
-- only update banned_ips from Redis once every ten seconds:
if updated_at == nil or updated_at < ( ngx.now() - 10 ) then
local redis = require "resty.redis";
local red = redis:new();
red:set_timeout(200);
local ok, err = red:connect("your-redis-hostname", 6379);
if not ok then
ngx.log(ngx.WARN, "Redis connection error retrieving banned_ips: " .. err);
else
local updated_banned_ips, err = red:smembers("banned_ips");
if err then
ngx.log(ngx.WARN, "Redis read error retrieving banned_ips: " .. err);
else
-- replace the locally stored banned_ips with the updated values:
banned_ips:flush_all();
for index, banned_ip in ipairs(updated_banned_ips) do
banned_ips:set(banned_ip, true);
end
banned_ips:set("updated_at", ngx.now());
end
end
end
if banned_ips:get(ngx.var.remote_addr) then
ngx.log(ngx.WARN, "Banned IP detected and refused access: " .. ngx.var.remote_addr);
return ngx.exit(ngx.HTTP_FORBIDDEN);
end
';}
現在就可以阻止特定IP的訪問:
1
ruby> $redis.sadd("banned_ips", "200.1.35.4")
Nginx進程每隔10秒從Redis獲取一次最新的禁止IP名單。需要注意的是,如果架構中使用了Haproxy這樣類似的負載均衡伺服器時, 需要把$remote_addr設置為正確的遠端IP地址。
這個方法還可以用於HTTP User-Agent欄位的檢查,要求滿足指定條件。
使用Nginx輸出CSRF(form_authenticity_token)
Mixlr大量使用頁面緩存,由此引入的一個問題是如何給每個頁面輸出會話級別的CSRF token。我們通過Nginx的子請求,從upstream web server 獲取token,然後利用Nginx的SSI(server-side include)功能輸出到頁面中。這樣既解決了CSRF攻擊問題,也保證了cache能被正常利用。
location /csrf_token_endpoint {
internal;
include /opt/nginx/conf/proxy.conf;
proxy_pass "http://upstream";}
location @dynamic {
ssi on;
set $csrf_token '';
rewrite_by_lua '
-- Using a subrequest, we our upstream servers for the CSRF token for this session:
local csrf_capture = ngx.location.capture("/csrf_token_endpoint");
if csrf_capture.status == 200 then
ngx.var.csrf_token = csrf_capture.body;
-- if this is a new session, ensure it sticks by passing through the new session_id
-- to both the subsequent upstream request, and the response:
if not ngx.var.cookie_session then
local match = ngx.re.match(csrf_capture.header["Set-Cookie"], "session=([a-zA-Z0-9_+=/+]+);");
if match then
ngx.req.set_header("Cookie", "session=" .. match[1]);
ngx.header["Set-Cookie"] = csrf_capture.header["Set-Cookie"];
end
end
else
ngx.log(ngx.WARN, "No CSRF token returned from upstream, ignoring.");
end
';
try_files /maintenance.html /rails_cache$uri @thin;}
CSRF token生成 app/metal/csrf_token_endpoint.rb:
class CsrfTokenEndpoint
def self.call(env)
if env["PATH_INFO"] =~ /^\/csrf_token_endpoint/
session = env["rack.session"] || {}
token = session[:_csrf_token]
if token.nil?
token = SecureRandom.base64(32)
session[:_csrf_token] = token
end
[ 200, { "Content-Type" => "text/plain" }, [ token ] ]
else
[404, {"Content-Type" => "text/html"}, ["Not Found"]]
end
endend
我們的模版文件示例:
<meta name="csrf-param" value="authenticity_token"/>
<meta name="csrf-token" value="<!--# echo var="csrf_token" default="" encoding="none" -->"/>
Again you could make use of lua_shared_dict to store in memory the CSRF token for a particular session. This minimises the number of trips made to /csrf_token_endpoint.
3. 如何將分頁數據 放入redis
普通分頁
一般分頁做緩存都是直接查找出來,按頁放到緩存里,但是這種緩存方式有很多缺點。
如緩存不能及時更新,一旦數據有變化,所有的之前的分頁緩存都失效了。
比如像微博這樣的場景,微博下面現在有一個頂次數的排序。這個用傳統的分頁方式很難應對。
一種思路
最近想到了另一種思路。
數據以ID為key緩存到Redis里;
把數據ID和排序打分存到Redis的skip list,即zset里;
當查找數據時,先從Redis里的skip list取出對應的分頁數據,得到ID列表。
用multi get從redis上一次性把ID列表裡的所有數據都取出來。如果有缺少某些ID的數據,再從資料庫里查找,再一塊返回給用戶,並把查出來的數據按ID緩存到Redis里。
在最後一步,可以有一些小技巧:
比如在缺少一些ID數據的情況下,先直接返回給用戶,然後前端再用ajax請求缺少的ID的數據,再動態刷新。
還有一些可能用Lua腳本合並操作的優化,不過考慮到Lua腳本比較慢,可能要仔細測試。
如果是利用Lua腳本的話,可以在一個請求里完成下面的操作:
查找某頁的所有文章,返回已緩存的文章的ID及內容,還有不在緩存里的文章的ID列表。
其它的一些東東:
Lua是支持LRU模式的,即像Memcached一樣工作。但是貌似沒有見到有人這樣用,很是奇怪。
可能是用redis早就准備好把redis做存儲了,也不擔心內存的容量問題。