Redis In Action:第二章 使用Redis构建Web应用

(二) 使用Redis构建Web应用

本章主要内容

  • 登录cookie
  • 购物车cookie
  • 缓存生成的网页
  • 缓存数据库行
  • 分析网页访问记录

web应用就是通过HTTP协议对网页浏览器发送的请求进行响应的服务器或者服务。一个典型的web服务器对请求进行响应的典型步骤:

(1)服务器对客户端发来的请求进行解析。

(2)请求被转发给一个预定义的处理器。

(3)处理器可能会从数据库中取出数据。

(4)处理器根据取出的数据对模板进行渲染。

(5)处理器向客户端返回渲染后的内容作为对请求的响应。

这种情况下的Web请求被认为是无状态的,也就是说,服务器本身不会记录与过往请求有关的任何信息,这使得失效的服务器可以很容易地被替换掉。

2.1 登录Cookie缓存

cookie由少量数据组成,网站会要求我们的浏览器存储这些数据,并在每次服务发送请求时将这些数据传回给服务器。对于用来登录的cookie,有两种常见的方法可以将登录信息存储在cookie里面:一种是签名(signed) cookie,另一种是令牌(token) cookie。

签名cookie通常会存储用户名,可能还有用户ID、用户最后一次成功登录的时间,以及网站觉得有用的其他任何信息。除了用户的相关信息之外,签名cookie还包含一个签名,服务器可以使用这个签名来验证浏览器发送的信息是否未经改动(比如将cookie中的登录用户名改成另一个用户)。

令牌cookie会在cookie里存储一串随机字节作为令牌,服务器可以根据令牌在数据库中查找令牌的拥有者。随着时间的推移,旧令牌会被新令牌取代。

图2-1 签名cookie和令牌cookie的优点和缺点

2.1.1 使用Redis实现cookie功能

使用Redis重新实现cookie功能,取代由关系数据库实现的登录cookie功能。

检查登录cookie

首先,使用一个散列来存储登录cookie令牌与已登录用户之间的映射。要检查一个用户是否已经登录,需要根据给定的令牌来查找与之对应的用户,并在用户已经登录的情况下,返回该用户的ID。

public String checkToken(Jedis conn, String token) {
    return conn.hget("login:", token);
}

更新令牌

用户每次浏览页面时,程序会对用户存储在登录散列里面的信息进行更新,并将用户的令牌和当前时间戳添加到记录最近登录的有序集合里面。如果用户正在浏览的是一个商品页面,那么程序还会将这个商品添加到记录这个用户最近浏览过的有序集合里面,并在记录商品的数量超过25个时,对这个有序集合进行修剪。

public void updateToken(Jedis conn, String token, String user, String item) {
    long timestamp = System.currentTimeMillis() / 1000;
    conn.hset("login:", token, user);
    conn.zadd("recent:", timestamp, token);
    if (item != null) {
        conn.zadd("viewed:" + token, timestamp, item);
        conn.zremrangeByRank("viewed:" + token, 0, -26);
    }
}

定时清理旧会话

存储会话数据所需的内存会随着时间的推移而不断增加,所以需要定期清理旧的会话数据。清理旧会话的程序由一个循环构成,这个循环每次执行的时候,都会检查存储最近登录令牌的有序集合的大小,如果有序集合的大小超过了限制,那么程序就会从有序集合里面移除最多100个最旧的令牌,并且从记录用户登录信息的散列里面,移除被删除令牌对应的用户的信息,并对存储了这些用户最近浏览商品的记录的有序集合进行清理。与此相反,如果令牌的数量未超过限制,那么程序会先休眠1秒,之后再重新进行检查。

while (!quit) {
    long size = conn.zcard("recent:");
    if (size <= limit){
        try {
            sleep(1000);
        }catch(InterruptedException ie){
            Thread.currentThread().interrupt();
        }
        continue;
    }

    long endIndex = Math.min(size - limit, 100);
    Set<String> tokenSet = conn.zrange("recent:", 0, endIndex - 1);
    String[] tokens = tokenSet.toArray(new String[tokenSet.size()]);

    ArrayList<String> sessionKeys = new ArrayList<String>();
    for (String token : tokens) {
        sessionKeys.add("viewed:" + token);
    }
    conn.del(sessionKeys.toArray(new String[sessionKeys.size()]));
    conn.hdel("login:", tokens);
    conn.zrem("recent:", tokens);
}

2.2 使用Redis实现购物车

购物车的定义:每个用户的购物车都是一个散列,这个散列存储了商品ID与商品订购数量之间的映射。对商品数量进行验证的工作由Web应用程序负责,redis要做的是在商品的订购数量出现变化时,对购物车进行更新。如果用户购买的商品已经存在于散列里面,那么新的订购数量会覆盖已有的订购数量;相反的,如果用户订购某件商品的数量不大于0,那么程序将从散列里面移除该条目。

更新购物车

public void addToCart(Jedis conn, String session, String item, int count) {
    if (count <= 0) {
        conn.hdel("cart:" + session, item);
    } else {
        conn.hset("cart:" + session, item, String.valueOf(count));
    }
}

接下来需要对之前的会话清理函数进行更新,使其在清理旧会话的同时,将旧会话对应用户的购物车也一并删除。

清理旧会话

while (!quit) {
    long size = conn.zcard("recent:");
    if (size <= limit){
        try {
            sleep(1000);
        }catch(InterruptedException ie){
            Thread.currentThread().interrupt();
        }
        continue;
    }

    long endIndex = Math.min(size - limit, 100);
    Set<String> sessionSet = conn.zrange("recent:", 0, endIndex - 1);
    String[] sessions = sessionSet.toArray(new String[sessionSet.size()]);

    ArrayList<String> sessionKeys = new ArrayList<String>();
    for (String sess : sessions) {
    	sessionKeys.add("viewed:" + sess);
    	sessionKeys.add("cart:" + sess);
    }

    conn.del(sessionKeys.toArray(new String[sessionKeys.size()]));
    conn.hdel("login:", sessions);
    conn.zrem("recent:", sessions);
}

2.3 网页缓存

通过对浏览数据进行分析,超过90%的web页面每天最多只改变一次,这些页面的内容实际上不需要动态生成。减少网站在动态生成内容上所花的时间,可以降低网站处理相同负载所需的服务器数量,并让网站的速度变得更快。

在这里创建一个中间件来调用Redis缓存函数:对于一个不能被缓存的请求,函数将直接生成并返回页面。对于可以被缓存的请求,函数首先会尝试从缓存里面取出并返回被缓存的页面,如果缓存页面不存在,那么函数会生成页面并将其缓存在Redis里面五分钟,最后再将页面返回给函数调用者。

public String cacheRequest(Jedis conn, String request, Callback callback) {
    if (!canCache(conn, request)){
        return callback != null ? callback.call(request) : null;
     }

    String pageKey = "cache:" + hashRequest(request);
    String content = conn.get(pageKey);

    if (content == null && callback != null){
        content = callback.call(request);
        conn.setex(pageKey, 300, content);
    }
    return content;
}

上述函数可以让网站在5分钟之内无需再为它们动态地生成视图页面。取决于网页的内容有多复杂,这一改动可以将包含大量数据的页面的延迟值从20~50毫秒降低至查询一次Redis所需的时间:查询本地Redis的延迟通常低于1毫秒,而查询位于同一个数据中心的Redis的延迟值通常低于5ms。对于需要访问数据库的页面来说,这个缓存函数对于减少页面载入时间和降低数据库负载的作用会更加显著。

2.4 数据行缓存

为了应对促销活动带来的大量负载,我们需要对数据行进行缓存,具体的做法是:编写一个持续运行的守护进程函数,让这个函数将指定的数据行缓存到Redis里面,并不定期地对这些缓存进行更新。缓存函数会将数据行编码为JSON字典并存储到Redis字符串里面,其中,数据列的名字会被映射为JSON字典的键,而数据行的值则会被映射为JSON字典的值。

图2-2 一个缓存的数据行实例

程序使用了两个有序集合来记录应该在何时对缓存进行更新:

第一个有序集合为调度有序集合,它的成员为数据行的行ID,而分值则是一个时间戳,这个时间戳记录了应该在何时将指定的数据行缓存到Redis里面;

第二个有序集合为延时有序集合,它的成员也是数据行的行ID,而分值则记录了指定数据行的缓存需要每隔多少秒更新一次。

为了让缓存函数定期地缓存数据行,程序首先需要将行ID和给定的延迟值添加到延迟有序集合里面,然后再将ID和当前时间的时间戳添加到调度有序集合里面。实际执行缓存操作的函数需要用到数据行的延迟值,如果某个数据行的延迟值不存在,那么程序将取消对这个数据行的调度。如果我们想要移除某个数据行已有的缓存,并且让缓存函数不再缓存那个数据行,那么只需要把那个数据行的延迟值设置为小于或等于0就可以了。

调度缓存:

public void scheduleRowCache(Jedis conn, String rowId, int delay) {
    conn.zadd("delay:", delay, rowId);
    conn.zadd("schedule:", System.currentTimeMillis() / 1000, rowId);
}

为了对数据行进行缓存,负责缓存数据行的函数会尝试读取调度有序集合的第一个元素以及该元素的分值,如果调度有序集合没有包含任何元素,或者分值存储的时间戳所指定的时间尚未来临,那么函数会先休眠50ms,然后再重新进行检查。当缓存函数发现一个需要立即进行更新的数据行是,缓存函数会检查这个数据行的延迟值:如果数据行的延迟值小于或者等于0,那么缓存函数会从延迟有序集合和调度有序集合里面移除这个数据行的ID,并从缓存里面删除这个数据行已有的缓存然后再重新进行检查;对于延迟值大于0的数据行来说,缓存函数会从数据库里面取出这些行,将它们编码为JSON格式并存储到Redis里面,然后更新这些行的调度时间。

守护进程:

Gson gson = new Gson();
while (!quit){
    Set<Tuple> range = conn.zrangeWithScores("schedule:", 0, 0);
    Tuple next = range.size() > 0 ? range.iterator().next() : null;
    long now = System.currentTimeMillis() / 1000;
    if (next == null || next.getScore() > now){
        try {
            sleep(50);
        }catch(InterruptedException ie){
            Thread.currentThread().interrupt();
        }
        continue;
    }

    String rowId = next.getElement();
    double delay = conn.zscore("delay:", rowId);
    if (delay <= 0) {
        conn.zrem("delay:", rowId);
        conn.zrem("schedule:", rowId);
        conn.del("inv:" + rowId);
        continue;
    }

    Inventory row = Inventory.get(rowId);
    conn.zadd("schedule:", now + delay, rowId);
    conn.set("inv:" + rowId, gson.toJson(row));
}

2.5 网页分析

网站可以从用户的访问、交互和购买行为中收集到有价值的信息。

更新token函数:

public void updateToken(Jedis conn, String token, String user, String item) {
    long timestamp = System.currentTimeMillis() / 1000;
    conn.hset("login:", token, user);
    conn.zadd("recent:", timestamp, token);
    if (item != null) {
        conn.zadd("viewed:" + token, timestamp, item);
        conn.zremrangeByRank("viewed:" + token, 0, -26);
        conn.zincrby("viewed:", -1, item);
    }
}

新的代码记录了所有商品的浏览次数,并根据浏览次数对此商品进行了排序,被浏览得最多的商品将被放到有序集合的索引0位置上,并且具有整个有序集合最少的分值。随着时间的流逝,商品的浏览次数会呈现两级分化的状态,一些商品的浏览次数会越来越多,而另一些商品的浏览次数则会越来越少。除了缓存最常被浏览的商品之外,程序还需要发现那些变得越来越流行的新商品,并在合适的时候缓存它们。

为了让商品浏览次数排行榜能够保持最新,我们需要定期修剪有序集合的长度并调整已有元素的分值,从而使得最新流行的商品也可以在排行榜里占据一席之地。调整元素分值可以通过ZINTERSTORE命令来完成。ZINTERSTORE命令可以组合起一个或多个有序集合,并将有序集合包含的每个分值都乘以一个给定的数值。每隔五分钟,守护进程函数会删除所有排名在20000名之后的商品,并将删除之后剩余的所有商品的浏览次数减半。

图2-3 守护进程函数

通过记录商品的浏览器次数,并定期对记录浏览次数的有序集合进行修剪和分值调整,可以建立起一个持续更新的最常浏览商品排行榜。

判断页面是否需要被缓存

public boolean canCache(Jedis conn, String request) {
    try {
        URL url = new URL(request);
        HashMap<String,String> params = new HashMap<String,String>();
        if (url.getQuery() != null){
            for (String param : url.getQuery().split("&")){
                String[] pair = param.split("=", 2);
                params.put(pair[0], pair.length == 2 ? pair[1] : null);
            }
        }

        String itemId = extractItemId(params);
        if (itemId == null || isDynamic(params)) {
            return false;
        }
        Long rank = conn.zrank("viewed:", itemId);
        return rank != null && rank < 10000;
    }catch(MalformedURLException mue){
        return false;
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值