专栏名称: SegmentFault思否
SegmentFault (www.sf.gg)开发者社区,是中国年轻开发者喜爱的极客社区,我们为开发者提供最纯粹的技术交流和分享平台。
目录
相关文章推荐
程序员的那些事  ·  GPU:DeepSeek ... ·  7 小时前  
OSC开源社区  ·  Bun ... ·  昨天  
程序员的那些事  ·  OpenAI ... ·  2 天前  
程序猿  ·  “未来 3 年内,Python 在 AI ... ·  5 天前  
51好读  ›  专栏  ›  SegmentFault思否

购物网站的 redis 相关实现(Java)

SegmentFault思否  · 公众号  · 程序员  · 2018-03-01 08:00

正文

本文主要内容:

  1. 登录cookie

  2. 购物车cookie

  3. 缓存数据库行

  4. 测试

必备知识点:

WEB应用就是通过HTTP协议对网页浏览器发出的请求进行相应的服务器或者服务(Service)。

一个WEB服务器对请求进行响应的典型步骤如下:

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

  2. 请求被转发到一个预定义的处理器(handler)

  3. 处理器可能会从数据库中取出数据

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

  5. 处理器向客户端返回渲染后的内容作为请求的相应

以上展示了典型的web服务器运作方式,这种情况下的web请求是无状态的(stateless),服务器本身不会记住与过往请求有关的任何信息,这使得失效的服务器可以很容易的替换掉。

每当我们登录互联网服务的时候,这些服务都会使用cookie来记录我们的身份。

cookies由少量数据组成,网站要求我们浏览器存储这些数据,并且在每次服务发出请求时再将这些数据传回服务。

对于用来登录的cookie ,有两种常见的方法可以将登录信息存储在cookie里:

  • 签名cookie通常会存储用户名,还有用户ID,用户最后一次登录的时间,以及网站觉得有用的其他信息。

  • 令牌cookie会在cookie里存储一串随机字节作为令牌,服务器可以根据令牌在数据库中查找令牌的拥有者。

签名cookie和令牌cookie的优点和缺点:

cookie类型 优点 缺点
签名cookie 验证cookkie所需的一切信息都存储在cookie,还可以包含额外的信息,对这些前面也很容易 正确的处理签名很难,很容易忘记,对数据签名或者忘记验证数据签名,从而造成安全漏洞
令牌cookie 添加信息非常容易,cookie体积小。移动端和较慢的客户端可以更快的发送请求 需要在服务器中存储更多信息,使用关系型数据库,载入存储代价高

因为该网站没有实现签名cookie的需求,所以使用令牌cookie来引用关系型数据库表中负责存储用户登录信息的条目。

除了登录信息,还可以将用户的访问时长和已浏览商品的数量等信息存储到数据库中,有利于更好的像用户推销商品

(1)登录和cookie缓存

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

  1. 将使用一个散列来存储登录cookie令牌与与登录用户之间的映射。

  2. 需要根据给定的令牌来查找与之对应的用户,并在已经登录的情况下,返回该用户id。

  1. public String checkToken(Jedis conn, String token) {

  2.    //1、String token = UUID.randomUUID().toString();

  3.    //2、尝试获取并返回令牌对应的用户

  4.    return conn.hget("login:", token);

  5. }

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

  1. public void updateToken(Jedis conn, String token, String user, String item) {

  2.    //1、获取当前时间戳

  3.    long timestamp = System.currentTimeMillis() / 1000;

  4.    //2、维持令牌与已登录用户之间的映射。

  5.    conn.hset("login:", token, user);

  6.    //3、记录令牌最后一次出现的时间

  7.    conn.zadd("recent:", timestamp, token);

  8.    if (item != null) {

  9.        //4、记录用户浏览过的商品

  10.        conn.zadd("viewed:" + token, timestamp, item);

  11.        //5、移除旧记录,只保留用户最近浏览过的25个商品

  12.        conn.zremrangeByRank("viewed:" + token, 0, -26);

  13.        //6、为有序集key的成员member的score值加上增量increment。通过传递一个负数值increment 让 score 减去相应的值,

  14.        conn.zincrby("viewed:", -1, item);

  15.    }

  16. }

存储会话数据所需的内存会随着时间的推移而不断增加,所有我们需要定期清理旧的会话数据。

清理会话的程序由一个循环构成,这个循环每次执行的时候,都会检查存储在最近登录令牌的有序集合的大小。如果有序集合的大小超过了限制,那么程序会从有序集合中移除最多100个最旧的令牌,并从记录用户登录信息的散列里移除被删除令牌对应的用户信息,并对存储了这些用户最近浏览商品记录的有序集合中进行清理。于此相反,如果令牌的数量没有超过限制,那么程序会先休眠一秒,之后在重新进行检查。

  1. public class CleanSessionsThread extends Thread {

  2.    private Jedis conn;

  3.    private int limit = 10000 ;

  4.    private boolean quit ;

  5.    public CleanSessionsThread(int limit) {

  6.        this.conn = new Jedis("localhost");

  7.        this.conn.select(14);

  8.        this.limit = limit;

  9.    }

  10.    public void quit() {

  11.        quit = true;

  12.    }

  13.    public void run() {

  14.        while (!quit) {

  15.            //1、找出目前已有令牌的数量。

  16.            long size = conn.zcard("recent:");

  17.            //2、令牌数量未超过限制,休眠1秒,并在之后重新检查

  18.            if (size <= limit) {

  19.                 try {

  20.                    sleep(1000);

  21.                } catch (InterruptedException ie) {

  22.                    Thread.currentThread().interrupt();

  23.                }

  24.                continue;

  25.            }

  26.            long endIndex = Math.min(size - limit, 100);

  27.            //3、获取需要移除的令牌ID

  28.            Set<String> tokenSet = conn.zrange("recent:", 0, endIndex - 1);

  29.            String[] tokens = tokenSet.toArray(new String[tokenSet.size()]);

  30.            ArrayList<String> sessionKeys = new ArrayList<String>();

  31.            for (String token : tokens) {

  32.                //4、为那些将要被删除的令牌构建键名

  33.                sessionKeys.add("viewed:" + token);

  34.            }

  35.            //5、移除最旧的令牌

  36.            conn.del(sessionKeys.toArray(new String[sessionKeys.size()]));

  37.            //6、移除被删除令牌对应的用户信息

  38.            conn.hdel("login:", tokens);

  39.            //7、移除用户最近浏览商品记录。

  40.            conn.zrem("recent:", tokens);

  41.        }

  42.    }

  43. }

(2)使用redis实现购物车

使用cookie实现购物车——就是将整个购物车都存储到cookie里面。

优点:无需对数据库进行写入就可以实现购物车功能。

缺点:怎是程序需要重新解析和验证cookie,确保cookie的格式正确。并且包含商品可以正常购买。还有一缺点,因为浏览器每次发送请求都会连cookie一起发送,所以如果购物车的体积较大,那么请求发送和处理的速度可能降低。

每个用户的购物车都是一个散列,存储了商品ID与商品订单数量之间的映射。如果用户订购某件商品的数量大于0,那么程序会将这件商品的ID以及用户订购该商品的数量添加到散列里。如果用户购买的商品已经存在于散列里面,那么新的订单数量会覆盖已有的。

相反,如果某用户订购某件商品数量不大于0,那么程序将从散列里移除该条目需要对之前的会话清理函数进行更新,让它在清理会话的同时,将旧会话对应的用户购物车也一并删除。

  1. public void addToCart(Jedis conn, String session, String item, int count) {

  2.    if (count <= 0) {

  3.        //1、从购物车里面移除指定的商品

  4.        conn.hdel("cart:" + session, item);

  5.    } else {

  6.        //2、将指定的商品添加到购物车

  7.        conn.hset("cart:" + session, item, String .valueOf(count));

  8.    }

  9. }

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

只是比CleanSessionsThread多了一行代码,伪代码如下:

  1. long endIndex = Math.min(size - limit, 100);

  2. //3、获取需要移除的令牌ID

  3. Set<String> tokenSet = conn.zrange("recent:", 0, endIndex - 1);

  4. String[] tokens = tokenSet.toArray(new String[tokenSet.size()]);

  5. ArrayList<String> sessionKeys = new ArrayList<String>();

  6. for (String token : tokens) {

  7.    //4、为那些将要被删除的令牌构建键名

  8.    sessionKeys.add("viewed:" + token);

  9.    //新增加的这两行代码用于删除旧会话对应的购物车。

  10.    sessionKeys.add("cart:" + sess);

  11. }

  12. //5、移除最旧的令牌

  13. conn.del(sessionKeys.toArray(new String[sessionKeys.size()]));

  14. //6、移除被删除令牌对应的用户信息

  15. conn.hdel("login:", tokens);

  16. //7、移除用户最近浏览商品记录。

  17. conn.zrem("recent:", tokens);

(3)数据行缓存

为了应对促销活动带来的大量负载,需要对数据行进行缓存,具体做法是:

  1. 编写一个持续运行的守护进程,让这个函数指定的数据行缓存到redis里面,并不定期的更新。

  2. 缓存函数会将数据行编码为JSON字典并存储在Redis字典里。其中数据列的名字会被映射为JSON的字典,而数据行的值则被映射为JSON字典的值。

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

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

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

为了让缓存函数定期的缓存数据行,程序首先需要将hangID和给定的延迟值添加到延迟有序集合里面,然后再将行ID和当前指定的时间戳添加到调度有序集合里面。

  1. public void scheduleRowCache(Jedis conn, String rowId, int delay) {

  2.    //1、先设置数据行的延迟值

  3.    conn.zadd("delay:", delay, rowId);

  4.    //2、立即对需要行村的数据进行调度

  5.    conn.zadd("schedule:", System.currentTimeMillis() / 1000, rowId);

  6. }

通过组合使用调度函数和持续运行缓存函数,实现类一种重读进行调度的自动缓存机制,并且可以随心所欲的控制数据行缓存的更新频率。

如果数据行记录的是特价促销商品的剩余数量,并且参与促销活动的用户特别多的话,那么最好每隔几秒更新一次数据行缓存:另一方面,如果数据并不经常改变,或者商品缺货是可以接受的,那么可以每隔几分钟更新一次缓存。

  1. public class CacheRowsThread

  2.        extends Thread {

  3.    private Jedis conn;

  4.    private boolean quit;

  5.    public CacheRowsThread() {

  6.        this.conn = new Jedis("localhost");

  7.        this.conn.select(14);

  8.    }

  9.    public void quit() {

  10.        quit = true;

  11.    }

  12.    public void run() {

  13.        Gson gson = new Gson();

  14.        while (!quit) {

  15.            //1、尝试获取下一个需要被缓存的数据行以及该行的调度时间戳,返回一个包含0个或一个元组列表

  16.            Set<Tuple> range = conn.zrangeWithScores("schedule:", 0, 0);

  17.            Tuple next = range.size() > 0 ? range.iterator().next() : null;

  18.            long now = System.currentTimeMillis() / 1000;

  19.            //2、暂时没有行需要被缓存,休眠50毫秒。

  20.            if (next == null || next.getScore() > now) {

  21.                try {

  22.                    sleep(50);

  23.                } catch (InterruptedException ie) {

  24.                    Thread.currentThread().interrupt();

  25.                }

  26.                continue;

  27.            }

  28.            //3、提前获取下一次调度的延迟时间,

  29.            String rowId = next.getElement();

  30.            double delay = conn.zscore("delay:", rowId);

  31.            if (delay <= 0) {

  32.                //4、不必在缓存这个行,将它从缓存中移除

  33.                conn.zrem("delay:", rowId);

  34.                conn.zrem("schedule:", rowId);

  35.                conn.del("inv:" + rowId);

  36.                continue;

  37.            }

  38.            //5、继续读取数据行

  39.            Inventory row = Inventory.get(rowId);

  40.            //6、更新调度时间,并设置缓存值。

  41.            conn.zadd("schedule:", now + delay, rowId);

  42.            conn.set("inv:" + rowId, gson.toJson(row));

  43.        }

  44.    }

  45. }

(4)测试

PS:需要好好补偿英语了!!需要全部的可以到这里下载官方翻译Java版(https://github.com/guoxiaoxu/redis-in-action)

  1. public class Chapter02 {

  2.    public static final void main(String[] args)

  3.            throws InterruptedException {

  4.            new Chapter02().run();

  5.    }

  6.    public void run()

  7.            throws InterruptedException {

  8.         Jedis conn = new Jedis("localhost");

  9.        conn.select(14);

  10.        testLoginCookies(conn);

  11.        testShopppingCartCookies(conn);

  12.        testCacheRows(conn);

  13.        testCacheRequest(conn);

  14.    }

  15.    public void testLoginCookies(Jedis conn)

  16.            throws InterruptedException {

  17.        System.out.println("\n----- testLoginCookies -----");

  18.        String token = UUID.randomUUID().toString();

  19.        updateToken(conn, token, "username", "itemX");

  20.        System.out.println("We just logged-in/updated token: " + token);

  21.        System.out.println("For user: 'username'");

  22.        System.out.println();

  23.         System.out.println("What username do we get when we look-up that token?");

  24.        String r = checkToken(conn, token);

  25.        System.out.println(r);

  26.        System.out.println();

  27.        assert r != null;

  28.        System.out.println("Let's drop the maximum number of cookies to 0 to clean them out");

  29.        System.out.println("We will start a thread to do the cleaning, while we stop it later");

  30.        CleanSessionsThread thread = new CleanSessionsThread(0);

  31.        thread.start();

  32.        Thread.sleep(1000);

  33.        thread.quit();

  34.        Thread.sleep(2000);

  35.        if (thread.isAlive()) {

  36.            throw new RuntimeException("The clean sessions thread is still alive?!?");

  37.        }

  38.         long s = conn.hlen("login:");

  39.        System.out.println("The current number of sessions still available is: " + s);

  40.        assert s == 0;

  41.    }

  42.    public void testShopppingCartCookies(Jedis conn)

  43.            throws InterruptedException {

  44.        System.out.println("\n----- testShopppingCartCookies -----");

  45.        String token = UUID.randomUUID().toString();

  46.        System.out.println("We'll refresh our session...");

  47.        updateToken(conn, token, "username", "itemX");

  48.        System.out.println("And add an item to the shopping cart");

  49.        addToCart(conn, token, "itemY", 3);

  50.        Map<String, String> r = conn.hgetAll("cart:" + token);

  51.        System.out.println("Our shopping cart currently has:");

  52.        for (Map.Entry<String, String> entry : r.entrySet()) {

  53.            System.out.println("  " + entry.getKey() + ": " + entry.getValue());

  54.        }

  55.        







请到「今天看啥」查看全文