需要
性能:P23
- 登录 cookie
- 购物车 cookie
- 缓存生成的网页
- 缓存数据库行
- 剖析网页拜访记录
高层次角度下的 Web 利用 P23
从高层次的角度来看,Web 利用就是通过 HTTP 协定对网页浏览器发送的申请进行响应的服务器或者服务(service)。Web 申请个别是无状态的(stateless),即服务器自身不会记录与过往申请无关的任何信息,使得生效的服务器能够很容易地被替换掉。
Web 服务器对申请进行响应的典型步骤:
- 服务器对客户端发来对申请(request)进行解析
- 申请被转发给一个预约义的处理器(handler)
- 处理器可能会从数据库中取数据
- 处理器依据数据对模板(templete)进行渲染(render)
- 处理器向客户端返回渲染后的内容作为对申请的响应(response)
根底数值量 P24
本次实际所有内容均围绕着发现并解决一个虚构的大型网上商店来开展的,一些根底数据量如下:
- 每天有 500 万名不同的用户
- 每天有 1 亿次点击
- 每天从网站购买超过 10 万件商品
实现
登录和 cookie 缓存 P24
有两种常见的办法能够将登录信息存储在 cookie 外面:
- 签名(signed)cookie:通常会存储用户名,可能还会有其余网站感觉游泳的信息,例如:最初一次胜利登录工夫、用户 id 等。还会有签名,用服务器验证 cookie 中的信息是否被批改。
- 令牌(token)cookie:存储遗传随机字节作为令牌,服务器依据令牌在数据库中查找令牌的领有着。随着工夫的推移,旧令牌会被新令牌取代。
签名 cookie 和令牌 cookie 的长处与毛病 P24
cookie 类型 | 长处 | 毛病 |
---|---|---|
签名 cookie | 验证 cookie 所需的所有信息都存储在 cookie 外面。cookie 能够蕴含额定的信息,并且对这些信息进行签名也很容易 | 正确地解决签名很难。很容易遗记对数据进行签名,或者遗记验证数据的签名,从而造成安全漏洞 |
令牌 cookie | 增加信息非常容易。cookie 的体积十分小,因而挪动终端和速度较慢的客户端能够更快地发送申请 | 须要在服务器存储更多信息。如果应用的是关系数据库,那么载入和存储 cookie 的代价可能会很高 |
本次实际采纳令牌 cookie 来援用存储的用户登录信息的条目。除登录信息外,还须要将用户拜访时长和已浏览商品的数量等信息存储到数据库外面,便于将来通过剖析这些信息来学习如何更好地向用户采购商品。
应用一个哈希表来存储登录 cookie 令牌与已登录用户之间的映射,依据给定的令牌查找对应的用户 id。P24
// redis key
type RedisKey string
const (
// 登录用户 哈希表(field:token;value:userId)LOGIN_USER RedisKey = "loginUser"
// 用户最近操作工夫 有序汇合
USER_LATEST_ACTION RedisKey = "userLatestAction"
// 用户最近浏览商品工夫 有序汇合 前缀(存储 itemId 及浏览的工夫戳)VIEWED_ITEM_PREFIX RedisKey = "viewedItem:"
// 用户购物车 哈希表 前缀(存储 itemId 及其加车数量)CART_PREFIX RedisKey = "cart:"
// 申请返回值缓存 字符串 前缀(存储 申请对应返回值的 序列化串)REQUEST_PREFIX RedisKey = "request:"
// 缓存数据距离(单位:ms)字符串
ITEM_INTERVAL RedisKey = "itemInterval"
// 数据缓存工夫(准确到毫秒)有序汇合
ITEM_CACHED_TIME RedisKey = "itemCachedTime"
// 数据(商品)的 json 字符串 前缀(存储 itemId 的相干信息)ITEM_PREFIX RedisKey = "item:"
// 商品浏览次数 有序汇合(存储 itemId 及浏览次数)ITEM_VIEWED_NUM RedisKey = "itemViewedNum"
)
// 依据 token 获取 userId(err 不为 nil 时,用户已登录且 userId 无效)func GetUserId(conn redis.Conn, token string) (userId int, err error) {return redis.Int(conn.Do("HGET", LOGIN_USER, token))
}
此时咱们曾经能通过 token 获取用户 id 了,还须要相应的设置办法,即用户每次操作时都会进行相干信息设置,并更新 token 的最近操作工夫戳。如果用户正在浏览一个商品,则还须要将该商品增加到浏览商品历史有序汇合中,且限度一个用户最多记录最新的 25 个商品浏览记录。P25
// 一个用户浏览的商品最多记录最新的 25 个
const MAX_VIEWED_ITEM_COUNT = 25
// 更新令牌相干信息(用户有操作是就会更新,如果以后操作是浏览商详页,则传入 itemId,否则 itemId <= 0)func UpdateToken(conn redis.Conn, token string, userId int, itemId int) {currentTime := time.Now().Unix() + int64(itemId)
// 更新令牌及相应 userId 对应关系
_, _ = conn.Do("HSET", LOGIN_USER, token, userId)
// 最近操作工夫 记录 token 的工夫戳
//(不能记录 userId 的工夫戳,userId 不会变,所以即便 token 更新了,userId 对应的工夫戳还是会更新,没法判断以后 token 是否过期)_, _ = conn.Do("ZADD", USER_LATEST_ACTION, currentTime, token)
// 以后浏览的时商品详情页时,会传入 itemId,否则 itemId <= 0
if itemId > 0 {
// 决定用 userId 当做后缀:token 可能会扭转,而 userId 是惟一确定的
viewedItemKey := VIEWED_ITEM_PREFIX + RedisKey(strconv.Itoa(userId))
// 增加(更新)最近浏览商品信息
_, _ = conn.Do("ZADD", viewedItemKey, currentTime, itemId)
// 移除工夫戳升序状态下 [0, 倒数第 MAX_VIEWED_ITEM_COUNT + 1 个] 内的所有元素,留下最近的 MAX_VIEWED_ITEM_COUNT 个
_, _ = conn.Do("ZREMRANGEBYRANK", viewedItemKey, 0, -(MAX_VIEWED_ITEM_COUNT + 1))
}
}
存储的数据会越来越多,且登录用户不会始终操作,所以能够设定最多反对的登录用户数,并定期删除超过的那些最久为操作的用户登录信息。P26
书上同时也将用户的历史拜访记录删除了,我这里没做删除,把存储历史拜访记录的有序汇合当作数据库用,与用户登录状态无关,即便用户的登录信息被删除了,仍旧能够剖析相应的数据,比拟符合实际应用状况。
清理办法外部死循环可能不太优雅,然而应用 go
关键字以协程运行,能够在肯定水平上达到定时工作的成果,且和大部分状况下定时工作一样,会随着主程序的退出而退出。
// 登录用户最多记录 1000w 条最新信息(其实这时候早曾经是大 key 了,不过以后场景不须要太过于思考这个)const MAX_LOGIN_USER_COUNT = 10000000
// 清理 session 理论距离 10s 运行一次
const CLEAN_SESSIONS_INTERVAL = 10 * time.Second
// 每次清理的个数
const CLEAN_COUNT = 1000
// 求 两个 int 的最小值
func min(a, b int) int {
if a < b {return a}
return b
}
// 合并 RedisKey 与 []string,返回一个 []interface{}
func merge(redisKey RedisKey, strs ...string) []interface{} {result := make([]interface{}, 1 + len(strs))
result[0] = redisKey
for i, item := range strs {result[i + 1] = item
}
return result
}
// 清理 session(因为大部分用户不会始终操作,所以须要定期清理 最旧的登录信息)// 外部死循环,可用 go 调用,当作定时工作
func CleanSessions(conn redis.Conn) {
for ; ; {loginUserCount, _ := redis.Int(conn.Do("ZCARD", LOGIN_USER))
// 超过最大记录数,则清理
if loginUserCount > MAX_VIEWED_ITEM_COUNT {
// 获取最旧的记录的 token,最多不超过 CLEAN_COUNT 个(多线程 / 分布式状况下会有并发问题,重点不在这里,暂不思考)cleanCount := min(loginUserCount - MAX_LOGIN_USER_COUNT, CLEAN_COUNT)
tokens, _ := redis.Strings(conn.Do("ZRANGE", USER_LATEST_ACTION, 0, cleanCount - 1))
// 不反对 []string 间接转 []interface{}(对字符串数组应用 ... 无奈对应参数 ...interface{})// 只有数组的元素类型 Type 雷同能力应用 ... 传参到相应的 ...Type
_, _ = conn.Do("HDEL", merge(LOGIN_USER, tokens...)...)
_, _ = conn.Do("ZREM", merge(USER_LATEST_ACTION, tokens...)...)
// 不删除用户的历史拜访记录,当作数据库用
}
// 每次执行完,期待 CLEAN_SESSIONS_INTERVAL 长时间
time.Sleep(CLEAN_SESSIONS_INTERVAL)
}
}
购物车 P28
每个用户的购物车是一个哈希表,存储了 itemId
与 商品加车数量之间的关系。此处购物车仅提供最根底的数量设置,加减数量的逻辑由调用者进行相干解决。
// 更新购物车商品数量(不思考并发问题)func UpdateCartItem(conn redis.Conn, userId int, itemId int, count int) {cartKey := CART_PREFIX + RedisKey(strconv.Itoa(userId))
if count <= 0 {
// 删除该商品
_, _ = conn.Do("HREM", cartKey, itemId)
} else {
// 更新商品数量
_, _ = conn.Do("HSET", cartKey, itemId, count)
}
}
购物车也和历史拜访记录一样,当作数据库应用,与用户登录态无关,不随登录态退出而删除。因而定期清理登录数据的代码不须要批改,也不必增加新函数。
网页缓存 P29
假如网站的 95% 页面每天最多只会扭转一次,那么没有必要每次都进行全副操作,能够间接在理论解决申请前将缓存的数据返回,既能升高服务器压力,又能晋升用户体验。
Java 中能够应用注解形式对特定的服务进行拦挡缓存,实现缓存的可插拔,防止批改外围代码。
Go 的话,不晓得如何去实现可插拔的形式,感觉能够利用 Java 中拦截器的形式,在申请散发到具体的办法前进行判断及缓存。我这里只进行简略的业务逻辑解决展现大抵流程,不关怀如何实现可插拔,让用户无感知。
// 判断以后申请是否能够缓存(随理论业务场景解决,此处不关怀,默认都能够缓存)func canCache(conn redis.Conn, request http.Request) bool {return true}
// 对申请进行哈希,失去一个标识串(随理论业务场景解决,此处不关怀,默认为 url)func hashRequest(request http.Request) string {return request.URL.Path}
// 对返回值进行序列化,失去字符串(随理论业务场景解决,此处不关怀,默认为 序列化状态码)func serializeResponse(response http.Response) string {return strconv.Itoa(response.StatusCode)
}
// 对缓存的后果进行反序列化,失去返回值(随理论业务场景解决,此处不关怀,默认 状态码为 200)func deserializeResponse(str string) http.Response {return http.Response{StatusCode: 200}
}
// 返回值缓存时长为 5 分钟
const CACHE_EXPIRE = 5 * time.Minute
// 缓存申请返回值
func CacheRequest(conn redis.Conn, request http.Request, handle func(http.Request) http.Response) http.Response {
// 如果以后申请不能缓存,则间接调用办法返回
if !canCache(conn, request) {return handle(request)
}
// 从缓存中获取缓存的返回值
cacheKey := REQUEST_PREFIX + RedisKey(hashRequest(request))
responseStr, _ := redis.String(conn.Do("GET", cacheKey))
// 序列化时,所有无效的缓存都有状态吗,所以必然不为 ""if responseStr !="" {return deserializeResponse(responseStr)
}
// 缓存中没有,则从新执行一遍
response := handle(request)
// 先进行缓存,再返回后果
responseStr = serializeResponse(response)
_, _ = conn.Do("SET", cacheKey, responseStr, "EX", CACHE_EXPIRE)
return response
}
数据缓存 P30
咱们不能缓存的常常变动的页面,然而能够缓存这些页面上的数据,例如:促销商品、热卖商品等。P30
当初网站须要进行促销,促销商品数量固定,卖完即止。为了保障用户近实时看到促销商品及数量,又要保障不给数据库带来微小压力,所以须要对促销商品的数据进行缓存。
能够用定时工作定期将须要缓存的数据更新到 Redis 中(其实对于促销等商品间接在缓存中进行相干库存扣减,既能保障实时数量,也能升高数据库压力,不过会存在热 key 问题)。因为不同的商品可能对实时性要求不一样,所以须要记录每个商品的更新周期和更新工夫,别离存成哈希表和有序汇合。P31
为了让定时工作定期缓存数据,须要提供一个函数,以设置更新周期和更新工夫。
// 毫秒转纳秒所需的倍数
const MILLI_2_NANO int64 = 1e6
// 更新数据缓存的更新周期(单位:ms)和更新工夫(准确到毫秒)func UpdateItemCachedInfo(conn redis.Conn, itemId int, interval int) {_, _ = conn.Do("HSET", ITEM_INTERVAL, itemId, interval)
_, _ = conn.Do("ZADD", ITEM_CACHED_TIME, time.Now().UnixNano() / MILLI_2_NANO, itemId)
}
定时工作定时获取第一个须要更新的商品,若更新工夫还未到,则期待下次执行。当更新周期不存在或者小于等于 0 时,示意不须要缓存,删除相干缓存;当更新周期大于等于 0 时,获取商品数据,并更新相干缓存。P32
// 定时工作每 50ms 执行一次
const CACHE_ITEM_INTERVAL = 50
// 获取商品数据的 json 串(随理论业务场景解决,此处不关怀,默认 只含有 itemId)func getItemJson(itemId int) string {return fmt.Sprintf("{\"id\":%d}", itemId)
}
// 缓存数据
// 外部死循环,可用 go 调用,当作定时工作
func CacheItem(conn redis.Conn) {
for ; ; {
// 获取第一个须要更新的商品(不思考没有商品的状况)result, _ := redis.Ints(conn.Do("ZRANGE", ITEM_CACHED_TIME, 0, 0, "WITHSCORES"))
itemId, itemCachedTime := result[0], result[1]
// 如果以后工夫还没到,则等下次执行
if int64(itemCachedTime) * MILLI_2_NANO > time.Now().UnixNano() {time.Sleep(CACHE_ITEM_INTERVAL * time.Millisecond)
continue
}
// 获取更新周期
interval, _ := redis.Int(conn.Do("HGET", ITEM_INTERVAL, itemId))
itemKey := ITEM_PREFIX + RedisKey(strconv.Itoa(itemId))
// 如果更新周期 小于等于 0,则移除相干缓存信息
if interval <= 0 {_, _ = conn.Do("HREM", ITEM_INTERVAL, itemId)
_, _ = conn.Do("ZREM", ITEM_CACHED_TIME, itemId)
_, _ = conn.Do("DELETE", itemKey)
continue
}
// 如果更新周期 大于 0,则还获取数据须要进行缓存
itemJson := getItemJson(itemId)
_, _ = conn.Do("SET", itemKey, itemJson)
_, _ = conn.Do("ZADD", ITEM_CACHED_TIME, time.Now().UnixNano() / MILLI_2_NANO + int64 (interval), itemId)
}
}
网页剖析 P33
当初网站只想将 100 000 件商品中用户最常常浏览的 10 000 件商品缓存,所以须要记录每件商品的总浏览次数,并可能获取到浏览次数最多的 10 000 件商品,所以须要用有序汇合进行存储记录。同时须要在 UpdateToken
退出减少次数的语句,更改后 UpdateToken
如下:P33
// 更新令牌相干信息(用户有操作是就会更新,如果以后操作是浏览商详页,则传入 itemId,否则 itemId <= 0)func UpdateToken(conn redis.Conn, token string, userId int, itemId int) {currentTime := time.Now().Unix() + int64(itemId)
// 更新令牌及相应 userId 对应关系
_, _ = conn.Do("HSET", LOGIN_USER, token, userId)
// 最近操作工夫 记录 token 的工夫戳
//(不能记录 userId 的工夫戳,userId 不会变,所以即便 token 更新了,userId 对应的工夫戳还是会更新,没法判断以后 token 是否过期)_, _ = conn.Do("ZADD", USER_LATEST_ACTION, currentTime, token)
// 以后浏览的时商品详情页时,会传入 itemId,否则 itemId <= 0
if itemId > 0 {
// 决定用 userId 当做后缀:token 可能会扭转,而 userId 是惟一确定的
viewedItemKey := VIEWED_ITEM_PREFIX + RedisKey(strconv.Itoa(userId))
// 增加(更新)最近浏览商品信息
_, _ = conn.Do("ZADD", viewedItemKey, currentTime, itemId)
// 移除工夫戳升序状态下 [0, 倒数第 MAX_VIEWED_ITEM_COUNT + 1 个] 内的所有元素,留下最近的 MAX_VIEWED_ITEM_COUNT 个
_, _ = conn.Do("ZREMRANGEBYRANK", viewedItemKey, 0, -(MAX_VIEWED_ITEM_COUNT + 1))
// 每次浏览商详页是,都要减少以后商品的浏览量
_, _ = conn.Do("ZINCRBY", ITEM_VIEWED_NUM, 1, itemId) //【改变点】}
}
此时,能够定时删除浏览量不在前 10 000 的商品缓存,同时为了保障新的热点商品可能不被已有的热点商品影响,所以在删除商品缓存后,要对没删除的商品次数进行折半解决。能够应用 ZINTERSTORE
,并配置 WEIGHTS
选项能够对所有商品分值乘以雷同的权重(当只有一个有序汇合时,ZUNIONSTORE
成果一样)P33
// 删除非热点商品缓存,重设热点商品浏览量 执行周期,每 5 分钟一次
const RESCALE_ITEM_VIEWED_NUM_INTERVAL = 300
// 热点商品浏览量权重
const ITEM_VIEWED_NUM_WEIGHT = 0.5
// 最大缓存商品数量
const MAX_ITEM_CACHED_NUM = 10000
// 删除非热点商品缓存,重设降权热点商品浏览量
// 外部死循环,可用 go 调用,当作定时工作
func RescaleItemViewedNum(conn redis.Conn) {
for ; ; {// 删除浏览量最小的 [0, 倒数 20 001] 商品,留下浏览量最大的 20 000 件商品
// 此处留下的浏览量记录是最大缓存商品数量的 2 倍,能够让新热点数据不被删掉
_, _ = conn.Do("ZREMRANGEBYRANK", ITEM_VIEWED_NUM, "0", -((MAX_ITEM_CACHED_NUM << 1) + 1))
// 浏览量折半,保障新热点数据不被影响太多
_, _ = conn.Do("ZINTERSTORE", ITEM_VIEWED_NUM, "1", ITEM_VIEWED_NUM, "WEIGHTS", ITEM_VIEWED_NUM_WEIGHT)
// 期待 5min 后,再执行下一次操作
time.Sleep(RESCALE_ITEM_VIEWED_NUM_INTERVAL * time.Second)
}
}
此处书中疑点
P33
倒数第二段:
新增加的代码记录了所有商品的浏览次数,并依据浏览次数对商品进行了排序,被浏览得最多的商品将被放到有序汇合的索引 0 地位上,并且具备整个有序汇合起码的分值。
相应 Python 代码片段:
conn.zincrby('viewed:', item, -1)
而进行删除排名在 20 000 名之后的商品操作时如下:
conn.zremrangebyrank('viewed:', 0, -20001)
Redis 命令 ZREMRANGEBYRANK
移除有序汇合中排名在区间内的所有元素(按升序排序),按此了解,上述代码的后果会留下浏览量最小的 20 000 个商品,与理论需要不符。
起初看到其余命令应用形式不同,想到可能是 Python 的命令有点不一样,网上一搜发现两种后果都有,还是要本人实践证明。
通过在 Python 中进行实际,上述删除操作确实是按升序排序,删除分值(浏览量)最低的局部,留下分值(浏览量)最高的局部。(当然,实际比较简单,有可能有其余配置会影响后果,就不再探索了)
接下来书上通过以下代码获取浏览量排名,并进行排名判断:P34
rank = conn.zrank('viewed:', item_id)
return rank is not None and rank < 10000
能够看到作者在此处的是合乎正数浏览量的设置形式的,然而负数浏览量能够通过 ZREVRANK
获取降序排名,能够猜想作者在删除排名在 20 000 后的商品时没有思考分明。
依照正数浏览量时,能够应用以下代码正确删除排名在 20 000 后的商品:
conn.zremrangebyrank('viewed:', 20000, -1)
至此,咱们就能够实现后面提到过的 canCache
函数,只有能被缓存的商品页面,并且排名在 10 000 内的商品页面能力被缓存。P34
// 从申请从获取 itemId,不存在则返回 谬误(随理论业务场景解决,此处不关怀,默认都是 1)func getItemId(request http.Request) (int, error) {return 1, nil}
// 判断以后申请是否是动静的(随理论业务场景解决,此处不关怀,默认都不是)func isDynamic(request http.Request) bool {return false}
// 判断以后申请是否能够缓存(随理论业务场景解决,此处不关怀,默认都能够缓存)func canCache(conn redis.Conn, request http.Request) bool {itemId, err := getItemId(request)
// 如果没有 itemId(即不是商品页面)或者 后果是动静的,则不能缓存
if err == nil || isDynamic(request) {return false}
// 获取申请的商品页面的浏览量排名
itemRank, err := redis.Int(conn.Do("ZREVRANK", ITEM_VIEWED_NUM, itemId))
// 如果 不存在 或者 排名大于 10000 名,则不缓存
if err != nil || itemRank >= MAX_ITEM_CACHED_NUM {return false}
return true
}
小结
- 要随着需要的扭转重构已有的代码
所思所想
- 上一次实际很多工夫都花在思考解决异样流程的业务逻辑上了,无用代码篇幅较大,且与分心学习
Redis
及相熟go
的初衷不合乎,所以本次开始就根本专一于如何去实现性能,不太思考一些参数校验和异样流程了。 - 实际是测验真谛的唯一标准。记得在读《Head First 设计模式》时也遇到了书本中存疑的中央,不能尽信书,还是要敢于质疑,用实际去验证各种可能性。
本文首发于公众号:满赋诸机(点击查看原文)开源在 GitHub:reading-notes/redis-in-action