Redis入门--头歌实验Redis命令实践

Redis 基于实用主义,它有着非常广泛的应用场景,例如:消息队列,缓存,排行榜等等。我们已经学习了 Redis 的常用命令,接下来开始在应用中使用这些命令吧!

一、使用Redis管理登录令牌

任务描述

本关任务:编写一个令牌管理的后端处理逻辑。

相关知识

大多数网站都会使用 cookie 记录用户的身份。cookie 是由少量数据组成的字符串(通常还要经过加密)。网站会要求浏览器存储这些数据,并在向服务端发起请求时将这些数据传回给服务端。

通常,用于处理登录(识别用户身份)的 cookie 分为两种:

  • 签名式 cookie
    • 存储包含用户 ID 等可直接识别用户的信息
    • 附加一个签名,核对 cookie 信息是否被恶意篡改
  • 令牌式 cookie
    • 存储一个随机字符串(令牌)
    • 通过在服务端的数据库中查找随机字符串和用户的对应关系识别用户身份

这两种 cookie 各有优缺,我们可以通过一个表格对比两者的优缺:

类型优点缺点
签名式 cookie直接存储用户信息,方便验证用户身份;可以包含额外信息;对 cookie 进行签名较简单遗漏签名会导致安全漏洞,加密方法不当会泄露用户敏感信息
令牌式 cookiecookie 体积小,可加快通信速度需要使用数据库存储令牌,会造成额外开销

为了避免安全漏洞,本关卡中,我们使用令牌式 cookie

为了完成本关任务,你需要掌握:1.如何核对令牌,2.如何更新令牌,3.如何定期清理无用信息。

如何核对令牌

前面提到了令牌式 cookie 的最大缺点就是会造成额外开销。大多数关系型数据库在每台数据库服务器上只能插入、更新或删除 200 - 2000 个数据行/秒。当网站的负载变高时,数据库就成为了瓶颈。所以,我们需要使用 Redis 取代关系型数据库,存放令牌。

首先,我们约定令牌的存储方式。我们使用一个哈希存储登录令牌与用户的映射关系,其中:

  • 哈希键名为 login
  • 登录令牌作为
  • 用户 ID 作为

所以核对令牌就变得十分简单,方法如下:

import redis
conn = redis.Redis()
def check_token(token):
    return conn.hget('login', token)

我们使用 hget() 方法从 Redis 中取出并返回令牌对应的用户 ID,而**当令牌不存在时,该方法则会返回 None**,从而达到核对检查的效果。

如何更新令牌

更新令牌需要做两个工作:

  • 记录用户令牌
  • 记录令牌生成时间

由于令牌存在被人窃取的可能,所以我们不允许令牌永不过期。通过记录令牌生成的时间戳,我们可以通过定期清理的方式,清理掉一定时间前生成(过老)的令牌,从而实现令牌的时限性,在一定程度上也减少了 Redis 的存储量,避免内存过高消耗。

使用有序集合记录令牌生成时间能让我们更便捷的根据时间戳对令牌进行排序,然后再对一定时间前生成(过老)的令牌进行删除。将时间戳作为分值,令牌作为成员,记录到有序集合 recent:token 中:

import time
timestamp = time.time()      #返回当前时间的时间戳(1970年后经过的浮点秒数)
conn.zadd('recent:token', token, timestamp)

而记录用户令牌可以使用 hset() 方法,将 tokenuser_id 的域-值对关系记录到哈希中:

conn.hset('login', token, user_id)

上述两个操作是相关的,记录了用户令牌则应该也有对应的令牌生成时间,所以我们应该使用事务将两条命令包起来,最后一起提交给 Redis 处理:

而记录用户令牌可以使用 hset() 方法,将 tokenuser_id 的域-值对关系记录到哈希中:

conn.hset('login', token, user_id)

上述两个操作是相关的,记录了用户令牌则应该也有对应的令牌生成时间,所以我们应该使用事务将两条命令包起来,最后一起提交给 Redis 处理:

def update_token(token, user_id):
    timestamp = time.time()
pipe = conn.pipeline()
pipe.hset('login', token, user_id)
pipe.zadd('recent:token', token, timestamp)
pipe.execute()
如何定期清理无用信息

随着登录用户的增多,令牌存储所需的内存也会不断增加,这时,我们需要定期清理过期的令牌数据。

决定令牌的有效时间需要权衡数据安全与用户体验两方面:

  • 有效时间过短,则用户需要频繁的输入账户密码登入系统
  • 有效时间过长,则令牌泄露的可能性增大,伪造用户身份的可能性也越大

综合上述两个方面的考虑,我们将令牌的有效时间设置为一个星期(86400秒),在每次清理令牌数据时,我们找到令牌生成时间在一个星期前的数据,并将这些令牌和令牌生成时间数据全部删除。

寻找一个星期前生成的令牌是关键,我们可以使用当前 Unix 时间减去 86400 得到一个星期前的 Unix 时间戳,然后使用有序集合命令 ZRANGEBYSCORE 获取有序集合 recent:token 中所有分值(生成时间)大于等于 0,小于等于一个星期前的 Unix 时间戳的成员:

one_week_ago_timestamp = time.time() - 86400
exipred_tokens = conn.zrangebyscore('recent:token', 0, one_week_ago_timestamp)

接下来,我们要从有序集合 recent:token 中删除掉 expired_tokens 的所有成员,以及从哈希 login 中移除所有过期的 token 域。

为了减少客户端与 Redis 之间的通信次数,我们可以直接使用 ZREMRANGEBYSCORE 命令移除有序集合 recent:token 中所有分值(生成时间)大于等于 0,小于等于一个星期前的 Unix 时间戳的成员:

conn.zremrangebyscore('recent:token', 0, one_week_ago_timestamp)
conn.hdel('login', *expired_tokens)

我们使用了 hdel() 方法一次性从哈希 login 中移除了所有过期的 token 域,由于 expired_tokens 是一个数组,而客户端会默认将输入的值转换为字符串,所以我们在这里要使用 *expired_tokens,以指针的形式调用 expired_tokens 变量,传入多个参数(域)

让清理方法自动执行

你可以使用守护进程的方式来保证这个方法始终在运行,也可以通过定时任务(cron job每隔一段时间执行一次

因为这些知识超出了本实训的讲解范围,在这里就不再详述。

编程要求

根据提示,在右侧Begin-End区域补充代码,完成令牌管理的后端处理逻辑:

  • 在 check_token(token) 方法中:
    • 使用 hget() 方法从哈希 login 中取出参数 token 域的值
    • 返回(return)上述值
  • 在 update_token(token, user_id) 方法中:
    • 参数说明:
      • token 为令牌
  • user_id 为该令牌对应的用户 ID
    • 获得当前时间并赋值给 timestamp
    • 使用事务提交下列命令:
      • 将域 token 与值 user_id 对存入哈希键 login 中
  • 将成员 token 存入有序集合 recent:token 中,分值为 timestamp
  • 在 clean_tokens() 方法中:
    • 使用当前时间减去 86400 得到一周前时间戳,并赋值给 one_week_ago_timestamp
    • 使用 zrangebyscore 方法获取有序集合 recent:token 中
      • 分值大于等于 0
  • 小于等于 one_week_ago_timestamp 的所有成员
  • 并赋值给变量 expired_tokens
    • 使用 zremrangebyscore 方法移除有序集合 recent:token 中
      • 分值大于等于 0
  • 小于等于 one_week_ago_timestamp 的所有成员
    • 移除哈希 login 中所有与变量 expired_tokens 中相同的域
      • 使用指针形式传入参数 *expired_tokens
测试说明

我会对你编写的代码进行测试:

测试输入:12345

预期输出:

loged user: []
[ADD!]User 1 add token
Login with 1's token, match user: 1, have_timestamp: True
loged user: ['1']
[ADD!]User 2 add token
Login with 2's token, match user: 2, have_timestamp: True
loged user: ['1', '2']
[ADD!]User 3 add token
Login with 3's token, match user: 3, have_timestamp: True
loged user: ['1', '2', '3']
[ADD!]User 4 add token
Login with 4's token, match user: 4, have_timestamp: True
loged user: ['1', '2', '3', '4']
[ADD!]User 5 add token
Login with 5's token, match user: 5, have_timestamp: True
Login with expired token
User not_exist_user add token
Clean Tokens
Login with expired token, match user: None
#!/usr/bin/env python
#-*- coding:utf-8 -*-

import time
import redis

conn = redis.Redis()

# 核对令牌,并返回该令牌对应的用户 ID
def check_token(token):
    # 请在下面完成要求的功能
    #********* Begin *********#
    return conn.hget('login', token)
    #********* End *********#

# 更新令牌,同时存储令牌的创建时间
def update_token(token, user_id):
    # 请在下面完成要求的功能
    #********* Begin *********#
    timestamp = time.time()
    pipe = conn.pipeline()
    pipe.hset('login', token, user_id)
    pipe.zadd('recent:token', token, timestamp)
    pipe.execute()

    #********* End *********#

# 清理过期令牌
def clean_tokens():
    # 请在下面完成要求的功能
    #********* Begin *********#
    one_week_ago_timestamp = time.time() - 86400
    expired_tokens = conn.zrangebyscore('recent:token', 0, one_week_ago_timestamp)
    conn.zremrangebyscore('recent:token', 0, one_week_ago_timestamp)
    conn.hdel('login', *expired_tokens)
    #********* End *********#

二、使用Redis实现购物车

任务描述

本关任务:实现购物车的后端处理逻辑。

相关知识

为了完成本关任务,你需要掌握:1.存储商品信息,2.存储购物车信息,3.获取购物车信息。

存储商品信息

商品包含多个属性,例如:名字,价格,描述等等。使用哈希能够将商品的所有信息存储在一个键 item:*:info,其中 * 是商品 ID,例如,商品 ID1 的哈希键 item:1:info 中的内容为:

{
"name": "儿童棉马甲加厚",
"price": 14.9
}

我们可以使用 hmset() 方法一次性存入商品信息:

item_info_key = 'item:' + str(item_id) + ':info'
conn.hmset(item_info_key, {"name": "儿童棉马甲加厚", "price": 14.9})

同时,商品并不是永久存在的,是有有效期的,在到达有效期后应该从 Redis 中删除,避免用户继续购买或者加入到购物车中。

由于一个商品的全部信息都存储在同一个键中,所以我们可以使用键的过期时间来设置商品信息哈希键 item:*:info 在指定的时间后过期:

conn.expire(item_info_key, 30 * 24 * 60 * 60)

上述例子中,我们设置商品信息在 30 天后过期。

将这些过程写入方法 add_item() 中:

def add_item(name, price):
item_id = conn.incr('item_id')   #设置item_id键自增
item_info_key = 'item:' + item_id + ':info'
conn.hmset(item_info_key, {"name": name, "price": price})
conn.expire(item_info_key, 30 * 24 * 60 * 60)

存储购物车信息

购物车的定义十分简单,我们可以将每个用户的购物车都看作是一个哈希,这个哈希存储着商品 ID 与加入购物车的数量之间的映射关系,由于购物车与用户相关,所以购物车的哈希键名为 cart:*,其中*是用户ID

在本关卡中,我们只处理商品加入/移出购物车时对购物车进行更新:

  • 当用户将某件商品加入到购物车时
    • 应该将该商品 ID 和加入购物车的数量添加到哈希中
    • 如果购物车中已有该商品,则应该根据新的数量更新哈希
  • 如果用户将某件商品移出购物车时
    • 应该从哈希中删除该商品 ID 对应的域

通过上述规则,可以将加入和移出购物车合并编写一个方法:

# 加入购物车
def add_to_cart(user_id, item, count):
    # 请在下面完成要求的功能
    #********* Begin *********#
    if count > 0:
        conn.hset('cart:' + user_id, item, count)
    else:
        conn.hrem('cart:' + user_id, item)

    #********* End *********#
获取购物车信息

实现了更新购物车的方法,但还需要一个方法来展示用户当前购物车中有什么商品,我们可以直接使用 hgetall() 方法获取到 cart:*(其中*为用户ID)键中所有域-值对:

def get_cart_info(user_id):
    return conn.hgetall('cart:' + user_id)

三、使用Redis做页面缓存

任务描述

本关任务:实现使用Redis缓存网页。

相关知识

为了完成本关任务,你需要掌握:1SETEX命令,2hash()方法。

在动态生成网页的时候,通常会使用模板(template)来简化网页的生成,现在已经不再需要我们手写一整个页面。通常,一个网页包括头部,尾部,侧边栏,工具栏和内容域等部分组成,每个部分都会独立使用一个模板来编写。

尽管都是动态的生成网页了,但大多数网站的内容都不会经常变化(大的变化),大多数网页的内容也是在一定周期内保持不变,这些网页就不需要动态生成。

本关卡中,我们会通过缓存的方式避免生成这些页面,减少动态生成页面所花费的时间,降低服务器的负载,提高网页访问速度。

我们需要在请求被响应之前,通过一个缓存函数判断:

  • 尝试从缓存中取出该请求的响应页面并返回
  • 若上述缓存不存在(失效),则:
    • 响应该请求,生成页面
    • 缓存至 Redis,生存时间为10分钟
    • 将该页面返回

 

我们可以使用字符串键来存储缓存页面,所以你可以使用 GET 命令尝试取出缓存页面,但当我们想要缓存页面时,则应该使用 SETEX 命令,该命令和 SET 命令的区别是,它是一个原子性(atomic)操作,关联值和设置生存时间两个动作会在同一时间内完成,所以它在 Redis 用作缓存时很常用。它的语法如下:

conn.setex(key, value, seconds)

其中:seconds 是键的生存时间,单位为秒。

我们将真实的请求响应简化一下,变为返回一个字符串 "content for http://xxx",这样我们整个缓存方法就是:

#!/usr/bin/env python
#-*- coding:utf-8 -*-

import redis

conn = redis.Redis()

# 使用 Redis 做页面缓存
def cache_request(request_url):
    # 生成页面的缓存键
    page_key = 'cache:' + str(hash(request_url))
    
    # 尝试从 Redis 中获取页面内容
    content = conn.get(page_key)
    
    # 如果页面内容不存在,则生成内容并设置缓存
    if not content:
        content = "content for " + request_url
        # 将内容设置到 Redis 中,有效期为 600 秒
        conn.setex(page_key, 600, content)
    
    # 返回页面内容
    return content

其中,我们使用了 hash() 方法将一个请求的 URL 地址通过哈希编码转化成为一个字符串,该字符串和 URL 一一对应,所以我们可以使用这个哈希值作为缓存的键。

编程要求

根据提示,在右侧Begin-End区域补充代码,实现使用Redis缓存网页:

  • 创建变量 page_key,值为:
    • 对参数 request_url 哈希编码并转化成字符串
    • 使用字符串 cache: 与上述字符串前后拼接
  • 尝试从 Redis 中读取字符串键,键名为 page_key 的值
    • 若读取成功,则返回该键中的值
    • 若读取失败,则:
      • 创建变量 content,值为:
    • 使用字符串 content for  与参数 request_url 前后拼接
  • 使用 SETEX 命令将变量 content 存至字符串键:
    • 键名为 page_key 的值
    • 生存时间为 600 秒

四、使用Redis做数据缓存

任务描述

本关任务:使用Redis实现数据缓存。

相关知识

为了完成本关任务,你需要掌握:1.将数据加入缓存队列,2.缓存数据。

上一关中,我们实现了使用 Redis 做页面缓存,同时我们也提到了还有少部分动态页面是不可以对整个页面进行缓存的,例如商品页面,用户详情页面等。尽管这些页面不可以使用页面缓存,但我们仍可以对其中动态内容所需要的数据进行缓存,从而加快动态页面绘制时读取数据的速度,减少页面载入所需的时间。

使用 Redis 做数据缓存的做法是:

  • 编写一个将数据加入缓存队列的函数
    • 通过一个有序集合 cache:list 存储数据加入缓存的时间
      • 成员为数据 ID(唯一标识)
  • 分值为当前时间(time.time()
    • 通过一个有序集合 cache:delay 存储数据更新周期
      • 成员为数据 ID(唯一标识)
  • 分值为更新周期,单位为秒
  • 编写一个定时缓存数据的函数
    • 将数据转换成 JSON 格式
    • 然后将上述 JSON 存储到 Redis 
    • 根据缓存更新周期定时更新 Redis 中的缓存键
将数据加入缓存队列

有序集合 cache:list 作为缓存队列,其需要依赖数据更新周期有序集合 cache:delay,加入某数据的更新周期不存在,那么我们则需要删除掉该数据的缓存。在这里,我们采用一种更便捷的方式避免数据缓存和取消数据缓存,那就是将该数据的更新周期设置为小于等于 0

将数据加入缓存队列需要同时操作这两个有序集合:

def add_cache_list(data_id, delay):
    # 将数据ID和延迟时间添加到缓存延迟有序集合中
    conn.zadd('cache:delay', data_id, delay)
    
    # 将数据ID和当前时间添加到缓存列表有序集合中
    conn.zadd('cache:list', data_id, time.time())
缓存数据

我们将数据加入到缓存队列后,就将有序集合 cache:list 的分值看作下一次要更新的时间,所以我们可以根据分值对有序集合 cache:list 进行排序,并连同分值一起取出从小到大顺序的第一个成员(最可能需要更新的成员):

conn.zrange('cache:list', 0, 0, withscores=True)

其中 withscores=True 会告诉 Redis 在返回成员时一同返回成员的分值,返回一个由零或一个元组组成的列表,例如:[(member1, score1)]

接下来,我们再判断该成员的分值:

  • 成员不存在/成员分值大于当前时间(还没有达到下一次更新的时间)
    • 等待 100 毫秒
    • 继续后续操作
  • 从有序集合 cache:delay 中取出该成员的更新周期
    • 若更新周期小于等于 0
  • 从有序集合 cache:delay 中删除该成员
  • 从有序集合 cache:list 中删除该成员
  • 删除该成员的缓存键 cache:data:*,其中 * 是数据 ID(唯一标识)
    • 若更新周期大于 0:
      • 将当前时间加上该成员的更新周期,重新存入有序集合 cache:list 中
      • 从数据库中获取到该数据值(这里可以使用伪造数据替代,例如:{'id':id, 'data':'fake data'}
      • 更新该成员的缓存键 cache:data:*,值为:上述数据编码成 JSON 格式 json(dumps(data))

将这些过程编写为 cache_row() 方法:

def cache_data():
    # 获取当前最早需要更新的数据
    next = conn.zrange('cache:list', 0, 0, withscores=True)
    now = time.time()
    
    # 如果没有下一个数据或者下一个数据的更新时间在当前时间之后,则等待0.1秒
    if not next or next[0][1] > now:
        time.sleep(0.1)
    
    # 获取下一个数据的ID
    data_id = next[0][0]
    
    # 获取数据的延迟时间
    delay = conn.zscore('cache:delay', data_id)
    
    # 如果延迟时间小于等于0,则从缓存中移除数据
    if delay <= 0:
        conn.zrem('cache:delay', data_id)
        conn.zrem('cache:list', data_id)
        conn.delete('cache:data:' + data_id)
    else:
        # 否则生成一个假数据,并更新下一个数据的更新时间,并将数据存入缓存
        data = {'id': data_id, 'data': 'fake data'}
        conn.zadd('cache:list', data_id, now + delay)
        conn.set('cache:data:' + data_id, json.dumps(data))

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

烟雨平生9527

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值