此處我說的HTTP服務主要指如訪問京東網站時我們看到的熱門搜索、用戶登錄、實時價格、實時庫存、服務支持、廣告語等這種非Web頁面,而是在Web頁面中異步加載的相關數據。這些服務有個特點即訪問量巨大、邏輯比較單一;但是如實時庫存邏輯其實是非常複雜的。在京東這些服務每天有幾億十幾億的訪問量,比如實時庫存服務曾經在沒有任何IP限流、DDos防禦的情況被刷到600多萬/分鐘的訪問量,而且能輕鬆應對。支撐如此大的訪問量就需要考慮設計良好的架構,並很容易實現水平擴展。
架構
此處介紹下我曾使用過Nginx+JavaEE的架構。
1、單DB架構
早期架構可能就是Nginx直接upstream請求到後端Tomcat,擴容時基本是增加新的Tomcat實例,然後通過Nginx負載均衡upstream過去。此時數據庫還不是瓶頸。當訪問量到一定級別,數據庫的壓力就上來了,此處單純的靠單個數據庫可能扛不住了,此時可以通過數據庫的讀寫分離或加緩存來實現。
2、DB+Cache/數據庫讀寫分離架構
此時就通過使用如數據庫讀寫分離或者Redis這種緩存來支撐更大的訪問量。使用緩存這種架構會遇到的問題諸如緩存與數據庫數據不同步造成數據不一致(一般設置過期時間),或者如Redis掛了,此時會直接命中數據庫導致數據庫壓力過大;可以考慮Redis的主從或者一致性Hash 算法做分片的Redis集羣;使用緩存這種架構要求應用對數據的一致性要求不是很高;比如像下訂單這種要落地的數據不適合用Redis存儲,但是訂單的讀取可以使用緩存。
3、Nginx+Lua+Local Redis+Mysql集羣架構
首先Nginx通過Lua讀取本機Redis緩存,如果不命中才回源到後端Tomcat集羣;後端Tomcat集羣再讀取Mysql數據庫。Redis都是安裝到和Nginx同一臺服務器,Nginx直接讀本機可以減少網絡延時。Redis通過主從方式同步數據,Redis主從一般採用樹的方式實現:
在葉子節點可以做AOF持久化,保證在主Redis掛時能進行恢復;此處假設對Redis很依賴的話,可以考慮多主Redis架構,而不是單主,來防止單主掛了時數據的不一致和擊穿到後端Tomcat集羣。這種架構的缺點就是要求Redis實例數據量較小,如果單機內存不足以存儲這麼多數據,當然也可以通過如尾號爲1的在A服務器,尾號爲2的在B服務器這種方式實現;缺點也很明顯,運維複雜、擴展性差。
4、Nginx+Lua+ Redis集羣+Mysql集羣架構
和之前架構不同的點是此時我們使用一致性Hash算法實現Redis集羣而不是讀本機Redis,保證其中一臺掛了,只有很少的數據會丟失,防止擊穿到數據庫。Redis集羣分片可以使用Twemproxy;如果 Tomcat實例很多的話,此時就要考慮Redis和Mysql鏈接數問題,因爲大部分Redis/Mysql客戶端都是通過連接池實現,此時的鏈接數會成爲瓶頸。一般方法是通過中間件來減少鏈接數。
Twemproxy與Redis之間通過單鏈接交互,並Twemproxy實現分片邏輯;這樣我們可以水平擴展更多的Twemproxy來增加鏈接數。
此時的問題就是Twemproxy實例衆多,應用維護配置困難;此時就需要在之上做負載均衡,比如通過LVS/HAProxy實現VIP(虛擬IP),可以做到切換對應用透明、故障自動轉移;還可以通過實現內網DNS來做其負載均衡。
本文沒有涉及Nginx之上是如何架構的,對於Nginx、Redis、Mysql等的負載均衡、資源的CDN化不是本文關注的點,有興趣可以參考
很早的Taobao CDN架構
Nginx/LVS/HAProxy負載均衡軟件的優缺點詳解
實現
接下來我們來搭建一下第四種架構。
以獲取如京東商品頁廣告詞爲例
假設京東有10億商品,那麼廣告詞極限情況是10億;所以在設計時就要考慮:
1、數據量,數據更新是否頻繁且更新量是否很大;
2、是K-V還是關係,是否需要批量獲取,是否需要按照規則查詢。
而對於本例,廣告詞更新量不會很大,每分鐘可能在幾萬左右;而且是K-V的,其實適合使用關係存儲;因爲廣告詞是商家維護,因此後臺查詢需要知道這些商品是哪個商家的;而對於前臺是不關心商家的,是KV存儲,所以前臺顯示的可以放進如Redis中。 即存在兩種設計:
1、所有數據存儲到Mysql,然後熱點數據加載到Redis;
2、關係存儲到Mysql,而數據存儲到如SSDB這種持久化KV存儲中。
基本數據結構:商品ID、廣告詞、所屬商家、開始時間、結束時間、是否有效。
後臺邏輯
1、商家登錄後臺;
2、按照商家分頁查詢商家數據,此處要按照商品關鍵詞或商品類目查詢的話,需要走商品系統的搜索子系統,如通過Solr或elasticsearch實現搜索子系統;
3、進行廣告詞的增刪改查;
4、增刪改時可以直接更新Redis緩存或者只刪除Redis緩存(第一次前臺查詢時寫入緩存);
前臺邏輯
1、首先Nginx通過Lua查詢Redis緩存;
2、查詢不到的話回源到Tomcat,Tomcat讀取數據庫查詢到數據,然後把最新的數據異步寫入Redis(一般設置過期時間,如5分鐘);此處設計時要考慮假設Tomcat讀取Mysql的極限值是多少,然後設計降級開關,如假設每秒回源達到100,則直接不查詢Mysql而返回空的廣告詞來防止Tomcat應用雪崩。
爲了簡單,我們不進行後臺的設計實現,只做前端的設計實現,此時數據結構我們簡化爲[商品ID、廣告詞]。另外有朋友可能看到了,可以直接把Tomcat部分幹掉,通過Lua直接讀取Mysql進行回源實現。爲了完整性此處我們還是做回源到Tomcat的設計,因爲如果邏輯比較複雜的話或一些限制(比如使用Java特有協議的RPC)還是通過Java去實現更方便一些。
項目搭建
項目部署目錄結構。
/usr/chapter6
redis_6660.conf
redis_6661.conf
nginx_chapter6.conf
nutcracker.yml
nutcracker.init
webapp
WEB-INF
lib
classes
web.xml
Redis+Twemproxy配置
此處根據實際情況來決定Redis大小,此處我們已兩個Redis實例(6660、6661),在Twemproxy上通過一致性Hash做分片邏輯。
安裝
之前已經介紹過Redis和Twemproxy的安裝了。
Redis配置redis_6660.conf和redis_6661.conf
#分別爲6660 6661
port 6660
#進程ID 分別改爲redis_6660.pid redis_6661.pid
pidfile "/var/run/redis_6660.pid"
#設置內存大小,根據實際情況設置,此處測試僅設置20mb
maxmemory 20mb
#內存不足時,按照過期時間進行LRU刪除
maxmemory-policy volatile-lru
#Redis的過期算法不是精確的而是通過採樣來算的,默認採樣爲3個,此處我們改成10
maxmemory-samples 10
#不進行RDB持久化
save “”
#不進行AOF持久化
appendonly no
將如上配置放到redis_6660.conf和redis_6661.conf配置文件最後即可,後邊的配置會覆蓋前邊的。
Twemproxy配置nutcracker.yml
server1:
listen: 127.0.0.1:1111
hash: fnv1a_64
distribution: ketama
redis: true
timeout: 1000
servers:
- 127.0.0.1:6660:1 server1
- 127.0.0.1:6661:1 server2
複製nutcracker.init到/usr/chapter6下,並修改配置文件爲/usr/chapter6/nutcracker.yml。
啓動
nohup /usr/servers/redis-2.8.19/src/redis-server /usr/chapter6/redis_6660.conf &
nohup /usr/servers/redis-2.8.19/src/redis-server /usr/chapter6/redis_6661.conf &
/usr/chapter6/nutcracker.init start
ps -aux | grep -e redis -e nutcracker
Mysql+Atlas配置
Atlas類似於Twemproxy,是Qihoo 360基於Mysql Proxy開發的一個Mysql中間件,據稱每天承載讀寫請求數達幾十億,可以實現分表、讀寫分離、數據庫連接池等功能,缺點是沒有實現跨庫分表(分庫)功能,需要在客戶端使用分庫邏輯。另一個選擇是使用如阿里的TDDL,它是在客戶端完成之前說的功能。到底選擇是在客戶端還是在中間件根據實際情況選擇。
此處我們不做Mysql的主從複製(讀寫分離),只做分庫分表實現。
Mysql初始化
爲了測試我們此處分兩個表。
CREATE DATABASE chapter6 DEFAULT CHARACTER SET utf8;
use chapter6;
CREATE TABLE chapter6.ad_0(
sku_id BIGINT,
content VARCHAR(4000)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE chapter6.ad_1
sku_id BIGINT,
content VARCHAR(4000)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Atlas安裝
cd /usr/servers/
wget https://github.com/Qihoo360/Atlas/archive/2.2.1.tar.gz -O Atlas-2.2.1.tar.gz
tar -xvf Atlas-2.2.1.tar.gz
cd Atlas-2.2.1/
#Atlas依賴mysql_config,如果沒有可以通過如下方式安裝
apt-get install libmysqlclient-dev
#安裝Lua依賴
wget http://www.lua.org/ftp/lua-5.1.5.tar.gz
tar -xvf lua-5.1.5.tar.gz
cd lua-5.1.5/
make linux && make install
#安裝glib依賴
apt-get install libglib2.0-dev
#安裝libevent依賴
apt-get install libevent
#安裝flex依賴
apt-get install flex
#安裝jemalloc依賴
apt-get install libjemalloc-dev
#安裝OpenSSL依賴
apt-get install openssl
apt-get install libssl-dev
apt-get install libssl0.9.8
./configure --with-mysql=/usr/bin/mysql_config
./bootstrap.sh
make && make install
實踐表明直接使用rpm安裝比較方便
https://github.com/Qihoo360/Atlas/wiki/Installing-Atlas
sudo rpm –i Atlas-XX.el6.x86_64.rpm
Atlas配置
vim /usr/local/mysql-proxy/conf/chapter6.cnf
[mysql-proxy]
#Atlas代理的主庫,多個之間逗號分隔
proxy-backend-addresses = 127.0.0.1:3306
#Atlas代理的從庫,多個之間逗號分隔,格式ip:port@weight,權重默認1
#proxy-read-only-backend-addresses = 127.0.0.1:3306,127.0.0.1:3306
#用戶名/密碼,密碼使用/usr/servers/Atlas-2.2.1/script/encrypt 123456加密
pwds = root:/iZxz+0GRoA=
#後端進程運行
daemon = true
#開啓monitor進程,當worker進程掛了自動重啓
keepalive = true
#工作線程數,對Atlas的性能有很大影響,可根據情況適當設置
event-threads = 64
#日誌級別
log-level = message
#日誌存放的路徑
log-path = /usr/chapter6/
#實例名稱,用於同一臺機器上多個Atlas實例間的區分
instance = test
#監聽的ip和port
proxy-address = 0.0.0.0:1112
#監聽的管理接口的ip和port
admin-address = 0.0.0.0:1113
#管理接口的用戶名
admin-username = admin
#管理接口的密碼
admin-password = 123456
#分表邏輯
tables = chapter6.ad.sku_id.2
#默認字符集
charset = utf8
因爲本例沒有做讀寫分離,所以讀庫proxy-read-only-backend-addresses沒有配置。分表邏輯即:數據庫名.表名.分表鍵.表的個數,分表的表名格式是table_N,N從0開始
Atlas啓動/重啓/停止
/usr/local/mysql-proxy/bin/mysql-proxyd chapter6 start
/usr/local/mysql-proxy/bin/mysql-proxyd chapter6 restart
/usr/local/mysql-proxy/bin/mysql-proxyd chapter6 stop
如上命令會自動到/usr/local/mysql-proxy/conf目錄下查找chapter6.cnf配置文件。
Atlas管理
通過如下命令進入管理接口
Java代碼 收藏代碼
mysql -h127.0.0.1 -P1113 -uadmin -p123456
通過執行SELECT * FROM help查看幫助。還可以通過一些SQL進行服務器的動態添加/移除。
Atlas客戶端
通過如下命令進入客戶端接口
Java代碼 收藏代碼
mysql -h127.0.0.1 -P1112 -uroot -p123456
Java代碼 收藏代碼
use chapter6;
insert into ad values(1 '測試1);
insert into ad values(2, '測試2');
insert into ad values(3 '測試3);
select * from ad where sku_id=1;
select * from ad where sku_id=2;
#通過如下sql可以看到實際的分表結果
select * from ad_0;
select * from ad_1;
此時無法執行select * from ad,需要使用如“select * from ad where sku_id=1”這種SQL進行查詢;即需要帶上sku_id且必須是相等比較;如果是範圍或模糊是不可以的;如果想全部查詢,只能挨着遍歷所有表進行查詢。即在客戶端做查詢-聚合。
此處實際的分表邏輯是按照商家進行分表,而不是按照商品編號,因爲我們後臺查詢時是按照商家維度的,此處是爲了測試才使用商品編號的。
到此基本的Atlas就介紹完了,更多內容請參考如下資料:
Mysql主從複製
http://369369.blog.51cto.com/319630/790921/
Mysql中間件介紹
http://www.guokr.com/blog/475765/
Atlas使用
http://www.0550go.com/database/mysql/mysql-atlas.html
Atlas文檔
https://github.com/Qihoo360/Atlas/blob/master/README_ZH.md
Nginx+Lua邏輯開發
核心代碼
/usr/chapter6/ad.lua
local redis = require("resty.redis")
local cjson = require("cjson")
local cjson_encode = cjson.encode
local ngx_log = ngx.log
local ngx_ERR = ngx.ERR
local ngx_exit = ngx.exit
local ngx_print = ngx.print
local ngx_re_match = ngx.re.match
local ngx_var = ngx.var
local function close_redis(red)
if not red then
return
end
--釋放連接(連接池實現)
local pool_max_idle_time = 10000 --毫秒
local pool_size = 100 --連接池大小
local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
if not ok then
ngx_log(ngx_ERR, "set redis keepalive error : ", err)
end
end
local function read_redis(id)
local red = redis:new()
red:set_timeout(1000)
local ip = "127.0.0.1"
local port = 1111
local ok, err = red:connect(ip, port)
if not ok then
ngx_log(ngx_ERR, "connect to redis error : ", err)
return close_redis(red)
end
local resp, err = red:get(id)
if not resp then
ngx_log(ngx_ERR, "get redis content error : ", err)
return close_redis(red)
end
--得到的數據爲空處理
if resp == ngx.null then
resp = nil
end
close_redis(red)
return resp
end
local function read_http(id)
local resp = ngx.location.capture("/backend/ad", {
method = ngx.HTTP_GET,
args = {id = id}
})
if not resp then
ngx_log(ngx_ERR, "request error :", err)
return
end
if resp.status ~= 200 then
ngx_log(ngx_ERR, "request error, status :", resp.status)
return
end
return resp.body
end
--獲取id
local id = ngx_var.id
--從redis獲取
local content = read_redis(id)
--如果redis沒有,回源到tomcat
if not content then
ngx_log(ngx_ERR, "redis not found content, back to http, id : ", id)
content = read_http(id)
end
--如果還沒有返回404
if not content then
ngx_log(ngx_ERR, "http not found content, id : ", id)
return ngx_exit(404)
end
--輸出內容
ngx.print("show_ad(")
ngx_print(cjson_encode({content = content}))
ngx.print(")")
將可能經常用的變量做成局部變量,如local ngx_print = ngx.print;使用jsonp方式輸出,此處我們可以將請求url限定爲/ad/id方式,這樣的好處是1、可以儘可能早的識別無效請求;2、可以走nginx緩存/CDN緩存,緩存的key就是URL,而不帶任何參數,防止那些通過加隨機數穿透緩存;3、jsonp使用固定的回調函數show_ad(),或者限定幾個固定的回調來減少緩存的版本。
vim /usr/chapter6/nginx_chapter6.conf
location ~ ^/ad/(\d+)$ {
default_type 'text/html';
charset utf-8;
lua_code_cache on;
set $id $1;
content_by_lua_file /usr/chapter6/ad.lua;
}
重啓nginx
/usr/servers/nginx/sbin/nginx -s reload