Redis 实战 —— 03. Redis 简单实践 - Web应用

需求

功能: P23
  • 登录 cookie
  • 购物车 cookie
  • 缓存生成的网页
  • 缓存数据库行
  • 分析网页访问记录
高层次角度下的 Web 应用 P23

从高层次的角度来看, Web 应用就是通过 HTTP 协议对网页浏览器发送的请求进行响应的服务器或者服务(service)。 Web 请求一般是无状态的(stateless),即服务器本身不会记录与过往请求有关的任何信息,使得失效的服务器可以很容易地被替换掉。

Web 服务器对请求进行响应的典型步骤:

  1. 服务器对客户端发来对请求(request)进行解析
  2. 请求被转发给一个预定义的处理器(handler)
  3. 处理器可能会从数据库中取数据
  4. 处理器根据数据对模板(templete)进行渲染(render)
  5. 处理器向客户端返回渲染后的内容作为对请求的响应(response)
基础数值量 P24

本次实践所有内容均围绕着发现并解决一个虚构的大型网上商店来展开的,一些基础数据量如下:

  • 每天有 500 万名不同的用户
  • 每天有 1 亿次点击
  • 每天从网站购买超过 10 万件商品

实现

有两种常见的方法可以将登录信息存储在 cookie 里面:

  • 签名(signed) cookie:通常会存储用户名,可能还会有其他网站觉得游泳的信息,例如:最后一次成功登录时间、用户 id 等。还会有签名,用服务器验证 cookie 中的信息是否被修改。
  • 令牌(token) cookie:存储遗传随机字节作为令牌,服务器根据令牌在数据库中查找令牌的拥有着。随着时间的推移,旧令牌会被新令牌取代。
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
Redis 实战 —— 03. Redis 简单实践 - Web应用

上一篇:Vintage、滚动率、迁移率的应用


下一篇:mysql 联系易错题知识点