《redis入门指南》进阶场景之用户注册和登录与在线保持

前面两节主要是对redis的基础知识的梳理,这一篇更多是作者对常见的应用场景的罗列和分析,一共囊括了如下五个应用场景:

  • 进行用户注册和登录的全流程
  • 保持用户在线的功能
  • 获取用户在线的好友
  • 通过IP地址查询访客所在地址
  • 搜索中常见的auto-complete功能

进行用户注册和登录

用户注册和登录的功能,常见的模块一般有三个:

  • 用户注册
  • 用户登录
  • 忘记密码
  • 安全防护

用户注册

这三个模块都有各自涉及到的技术点。首先是用户注册模块,要满足用户注册的需求,首先明确用户信息存储所需要的数据结构。一般而言,一个基本的用户注册,需要如下的字段:

  • 用户名
  • 邮箱
  • 密码
  • 注册时间
  • 手机等其他需要额外标注的信息
  • 可扩展的字段(你不知道之后业务的形态发展,如果你不想连表查询的话)

那么在这种需求之下,redis中的哈希类型来存储用户的基本信息是非常适合的,从存储优势上面来讲,使用哈希类型的存储能够让每个用户的非必填字段不占用多余的内存。那么每次创建用户的时候,只需要在业务层保证用户必填的字段都存在,然后插入新用户的信息即可:
HSET user:$userid userName $userName email $email password $password registerTime $registerTime ...

与此同时,为了保证用户注册的唯一性,我们将用户的邮箱作为独一无二的存在,每次新用户注册的时候,只需要使用HEXISTS user:$userid email $email就可以判断出用户是否注册过。这样做的好处是显而易见的,很多热门的产品,都会进行账号的抢注。那么在抢注的过程中,一个高效的,用户是否已经注册的服务是非常有必要的。否则一旦过载,会使得所有的注册用户都被拒之门外,这对于产品的伤害性无疑是巨大的。

再者,就像每一个web安全的教程里都会说的,密码不能被明文存储。这点无论是对mysql还是redis来存储密码都是毫无疑问的。常见的方式是对密码使用不可逆的单向加密算法,比如Md5算法(应该说是一种摘要的算法),同时在生成最终存储的密码时还需要一个由服务端控制的对应每个用户的盐来避免可能的Rainbow table的攻击。这个盐一般会写在服务端的代码配置或是在用户表中增加一个字段进行单独的存储。但是对这个盐的维护成本较高, 而且没有必要。

所以更好的方式其实是使用bcrypt算法,他主要有几个不错的优势:

  • 它的算法是渐进式的,会随着你尝试的次数增加而增加计算的时间,也就是说如果有人用brute-force攻击的话,那么他就必须具备大量的资源,也就是大量的肉鸡来尝试;
  • 它使用了blowfish算法来对密码进行哈希,这样做的优势在于加密的每一步都完全取决于salt和用户的密码,攻击者很难在没有两者的情况下进行模拟;
  • 它是单向的加密算法;
  • 可配置的round参数,round为5的情况下,一个6位的密码破解需要数年的时间;
  • 无需存储salt,算法本身会把salt加入到最终生成的值中,这就省去了对salt的单独处理

关于bcrypt,有几篇不错的post推荐:

由此,我们就解决了用户注册中最大头的用户信息存储、用户注册校验,以及用户密码加密的三个部分。接下来继续进入到用户登录与忘记密码模块。

用户登录与忘记密码

对于每个用户,我们需要用用户id进行关联,这个用户id同时也可能关联了其他的业务相关的存储。这些存储并不一定是redis或是mysql,但是必须要有这么一个表示保证业务的连通性。所以我们单独保存了邮箱与用户id的映射关系。而每当用户登陆的时候,我们就可以:

  • 通过邮箱,根据映射获取用户的id
  • 与redis中存储的哈希数据对比邮箱和密码

如果验证通过,那么用户登录成功,通过相应的方式保持用户的在线状态。这里简略的说两种方式,后文会详述:

  • 浏览器cookie存储,通过cookie保存用户的登录标识
  • 后端保持登录,服务器session保持用户的登录状态

另外一个常见的场景就是与邮件相关的,比如忘记密码,比如校验邮箱的有效性。这些功能都需要异步的发送邮件,同时在用户进行相应的链接操作之后,修改特定的状态。我们以修改密码为例,一旦用户发出了修改密码的请求,那么需要做如下几件事情:

  • 邮箱有效,发送修改密码的邮件;
  • 在发送的邮件中的修改密码的链接中,生成随机的验证码,并且设置固定的有效期为一个小时(expire命令);
  • 提供校验验证码的服务,如果用户在一个小时之内点击跳转,并且验证码校验通过,提供修改密码的页面;

与此同时需要注意的是,必须对用户此类涉及到邮箱的服务,进行访问频率限制,从而防止大量无效的邮箱发送请求。

用户在线保持

用户在线保持的方案有很多,上文中也提到了,主要是分成两个流派。
第一个流派就是使用前端的存储来记录用户的状态,包括但不限于cookie、端的本地存储等等。每次用户请求的时候,都会带上这个客户端的标识,而服务端提供相应的校验机制。这么做的好处是服务端实现比较简单,只需要做两件事:

  • 在用户登录的时候,种上有一定时间期限的标识
  • 用户请求的时候,对于此类标识进行校验

但是不可避免的,使用客户端存储用户标识会有一定的安全风险,其中的风险在于:

  • cookie可能会被xss攻击获得,从而破解了用户的登录身份
  • 一些不应该暴露给用户的信息,通过本地存储的方式暴露给用户了,存在潜在的安全风险。

因此,第二个流派就是服务端的登录态保持。这个登录态保持由来已久,之前最经典就是服务端的session。其依赖于一个sessionid(一般也是存储在cookie中的)作为key,然后将用户的登录信息存储在服务端。存储在服务端的方式有很多,redis作为服务端内存存储的佼佼者,当然是一个不错的方式。

首先我们使用一个散列来存储sessionid与已登录用户之间的映射。要检查一个用户是否登录,需要根据给定的sessionid来查找与之对应的用户,并在他已经登录的情况下,返回用户的ID。
hget login: $sessionid

而比较复杂的情况出现在用户的登录态刷新上,每当用户浏览页面的时候,我们都对用户存储在登录散列里面的信息进行更新,并将用户的sessionid和当前时间戳添加到记录最近登录用户的有序集合中。

1
2
hset login: $sessionid $user
zadd recent: $sessionid time()

一般而言,单台redis的存储容量有限,并且为了不占用不必要的内存,我们肯定需要对用户的登录状态进行清理。假定产品设计只需要同时保持100万个用户在线,那么我们可以通过定时脚本的方式,在存储超限的情况下,找到最旧的sessionid,将其清理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
while
size = redis.zcard('recent:')
if size <= LIMIT
time.sleep(1)
continue
end_index = min(size - LIMIT ,100 ) //每次不要删除太多
sessionids = redis.zrange('recent:',0,end_index-1)
for sessionid in sessionids:
session_key = 'delete:'+sessionid
multi
delete session_key
hdel login: session_key
zrem recent: session_key
exec

在实际运行时,每秒最多可以清理60000多个令牌,完全够用了。

本文主要对redis在登录注册、登录态保持方面的应用进行了探讨,同时也结合了一些我的实际使用与开发的经验。有人说redis的使用非黑即白,需要摒弃关系型数据库。但是在实际的工程中,颇为不可行,很多系统仍然依赖于关系型数据库存储的方方面面,这点在后文中也会有所体现。接下来的一文,会对用户的好友列表拉取、auto-complete实现、以及ip地址查询访客等功能进行更进一步的讨论。

热评文章