- 作者/ 馬達資料CTO 劉嘉瑜
最近我發現我的 Express 應用特別慢。
背景介紹是這樣:我們在 Madadata 裡建立平臺,在這個平臺中,我們使用了一個外部服務 Leancloud 來提供使用者身份驗證和註冊。 但是,為了在對 API 進行測試時,不用忍受從加州(我們 CI 伺服器所在地)到上海(我們的服務提供商伺服器所在地)發請求的痛苦,我用 Express 和 Mongoose 寫了一個簡單的模擬 API 服務。
在我們最近開始進行負載測試前,我們都沒有意識到這個模擬服務的延遲:超過一半的請求在1秒內沒有返回,從而導致負載測試失敗。作為一個簡單的使用 Mongoose 的 Express 應用程式,幾乎沒有任何寫錯的機會,至少,不會延遲1秒這麼多。
上面,本地進行 mocha 測試時的截圖顯示,很明顯 API 服務確實有些問題!
什麼地方出了錯?
從螢幕截圖我可以看出,並不是所有API都是慢的:使用者登出的那個 API,以及顯示當前個人檔案的 API 都速度正常。此外,從我用 morgan 列印出來的開發日誌中,我發現那些速度緩慢的 API,由 Express 收集的響應時間都顯示出一致的延遲水平(即,那些用紅色標記的 API,你可以看到,他們的總延遲大致分別來自兩個請求)。
這實際上排除了“延遲是因為連線問題”的可能性(而是因為 Express 應用本身)。所以下一步,我看了一下我的 Express 應用程式。(注意,這實際上是值得排除,我個人建議嘗試一兩個其它工具,而不是來關注 mocha
,例如嘗試 curl
甚至 nc
,然後再繼續,因為它們幾乎總是比你寫的測試程式碼更可靠)。
Express 應用內部
如果提起 Node 的 Web 伺服器,Express 真的是一個很好的框架,它在速度和可靠性方面已經取得了很大進展。我想,延遲可能主要是因為我用的 Express 中的外掛和中介軟體。
為了使用 MongoDB 作為會話儲存,我使用了 connect-mongo 來配合我的 expression session。我也使用了相同的 MongoDB instance 作為我的主憑證和配置檔案儲存(為什麼不呢?畢竟,這是個 CI 測試的服務)。因此,我使用了 Mongoose 作為 ODM 。
起初,我懷疑可能是因為使用了 Mongoose 內建的 Promise library。但是,在我換成了 ES6 原生實現之後,問題並沒有解決。
然後,我就覺得應該檢查一下模型序列化和驗證部分。 應用裡只有一個模型,它相當簡單直接:
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const isEmail = require('validator/lib/isEmail')
const isNumeric = require('validator/lib/isNumeric')
const passportLocalMongoose = require('passport-local-mongoose')
mongoose.Promise = Promise
const User = new Schema({
email: {
type: String,
required: true,
validate: {
validator: isEmail
},
message: '{VALUE} 不是一個合法的 email 地址'
},
phone: {
type: String,
required: true,
validate: {
validator: isNumeric
}
},
emailVerified: {
type: Boolean,
default: false
},
mobilePhoneVerified: {
type: Boolean,
default: false
},
turbineUserId: {
type: String
}
}, {
timestamps: true
})
User.virtual('objectId').get(function () {
return this._id
})
const fields = {
objectId: 1,
username: 1,
email: 1,
phone: 1,
turbineUserId: 1
}
User.plugin(passportLocalMongoose, {
usernameField: 'username',
usernameUnique: true,
usernameQueryFields: ['objectId', 'email'],
selectFields: fields
})
module.exports = mongoose.model('User', User)複製程式碼
Mongose Hooks
Mongoose 有一個很好的功能,你可以使用 pre-
和 post-hooks
來檢查文件的驗證和儲存過程。
使用 console.time
和 console.timeEnd
,我們就可以實際測量在這些程式中花費的時間。
User.pre('init', function (next) {
console.time('init')
next()
})
User.pre('validate', function (next) {
console.time('validate')
next()
})
User.pre('save', function (next) {
console.time('save')
next()
})
User.pre('remove', function (next) {
console.time('remove')
next()
})
User.post('init', function () {
console.timeEnd('init')
})
User.post('validate', function () {
console.timeEnd('validate')
})
User.post('save', function () {
console.timeEnd('save')
})
User.post('remove', function () {
console.timeEnd('remove')
})複製程式碼
然後我們得到了 mocha 執行中更詳細的資訊:
顯然,文件驗證和儲存根本不是造成延遲的主要原因。它也排除了這兩個可能性:1)延遲來自於 Express 應用程式和 MongoDB 伺服器之間的連線問題,或者 2)MongoDB 伺服器本身執行緩慢。
Passport + Mongoose
當我把焦點從 Mongoose 身上移開,我開始看到我使用的 passport 外掛:passport-local-mongoose。
這個名字是有點長,但它基本上告訴了你它是幹嘛的。Passport 負責會話管理、註冊和登入樣板,而Passport-local-mongoose 則將 Mongoose 轉變為 passport 的本地策略。
這個 library 小而且簡單,所以我開始直接在 node_modules/
資料夾 中編輯我的 index.js
檔案。由於函式 #register (user, password, cb)
呼叫函式 #setPassword (password, cb)
,也就是這一行,所以我開始注意後者。在新增了更多的 console.time
和 console.timeEnd
之後,我確認了,原來延遲主要是由於這個函式呼叫:
pbkdf2(password, salt, function(pbkdf2Err, hashRaw) {
// omit
}複製程式碼
PBKDF2
這個名稱本身就表示它是一個加密 library 的呼叫。再看 README 可以發現,這個 library 使用了 25,000
次迭代。
像 bcrypt 一樣,pbkdf2
也是一個緩慢的雜湊演算法,也就是說,它就是偏延遲的,這個延遲可以在迭代時進行調整,來適應不斷增強的計算能力。這個概念被稱為:金鑰延伸 (key streching)。
如維基百科裡寫的,最開始提出的迭代次數是 1,000
次,而最近一次的更新達到了 100,000
次。所以其實預設的 25,000
次是合理的。
在將迭代減少到 1,000
後,我的 mocha 測試輸出如下:
最後,終於,這個延遲和安全性變得可以接受了,畢竟它只是個測試應用程式!注意,我為我的測試應用程式做了這個更改,並不意味著你也要減少你的應用程式的迭代次數。另外,將迭代次數設定得太高,會使應用程式容易受到 DoS 攻擊。
最後的想法
我想,分享一些 debug 經驗還是有意義的,我很高興這不真的是一個 bug(對,是一個偽裝起來的功能)。
另外值得一提的是,對於對電腦保安或密碼學不是很瞭解的開發人員來說,通常,最好不要自己寫一些與 會話 / 金鑰 / 令牌管理 相關的程式碼。使用好的、如 passport 這樣的開源庫,會更好。
不過,你永遠不會知道在 debug Web 伺服器時會遇到什麼坑——但這才是它最有趣的地方!