Redis入门指南

李子骅  编著

人民邮电出版社

北京

图书在版编目（CIP）数据

Redis入门指南 / 李子骅 编著. --北京：人民邮电出版社，2013.4

ISBN 978-7-115-31294-5

Ⅰ. ①R… Ⅱ. ①李… Ⅲ. ①数据库—基本知识 Ⅳ.①TP311.13

中国版本图书馆CIP数据核字（2013）第050956号

内容提要

本书是一本Redis的入门指导书籍，以通俗易懂的方式介绍了Redis基础与实践方面的知识，包括历史与特性、在开发和生产环境中部署运行Redis、数据类型与命令、使用Redis实现队列、事务、复制、管道、持久化、优化Redis存储空间等内容，并采用任务驱动的方式介绍了PHP、Ruby、Python和Node.js这4种语言的Redis客户端库的使用方法。

本书的目标读者不仅包括Redis的新手，还包括那些己经掌握Redis使用方法的人。对于新手而言，本书的内容由浅入深且紧贴实践，旨在让读者真正能够即学即用；对于己经了解Redis的读者，通过本书的大量实例以及细节介绍，也能发现很多新的技巧。

Redis入门指南

◆编著  李子骅

责任编辑  杨海玲

◆人民邮电出版社出版发行    北京市崇文区夕照寺街14号

邮编  100061    电子邮件  315@ptpress.com.cn

网址  http://www.ptpress.com.cn

北京鑫正大印刷有限公司印刷

◆开本：800×1000 1/16

印张：12

字数：253千字    2013年5月第1版

印数：1-3500册    2013年5月北京第1次印刷

ISBN 978-7-115-31294-5

定价：39.00元

读者服务热线：（010）67132692 印装质量热线：（010）67129223

反盜版热线：（010）67171154

前言

Redis如今已经成为Web开发社区中最火热的内存数据库之一，而它的诞生距现在不过才4年。随着Web 2.0的蓬勃发展，网站数据快速增长，对高性能读写的需求也越来越多，再加上半结构化的数据比重逐渐变大，人们对早已被铺天盖地地运用着的关系数据库能否适应现今的存储需求产生了疑问。而Redis的迅猛发展，为这个领域注入了全新的思维。

Redis 凭借其全面的功能得到越来越多的公司的青睐，从初创企业到新浪微博这样拥有着几百台Redis服务器的大公司，都能看到Redis的身影。Redis也是一个名副其实的多面手，无论是存储、队列还是缓存系统，都有它的用武之地。

本书将从Redis的历史讲起，结合基础与实践，带领读者一步步进入Redis的世界。

目标读者

本书假定读者是Redis的新手，甚至可能连Redis是什么都没听说过。本书将会详细地介绍Redis是什么以及为什么要使用Redis，旨在能够让读者从零开始逐步晋升为一个优秀的Redis开发者。

本书还包含了很多Redis实践方面的知识，对于有经验的Redis开发者，大可以直接跳过已经掌握的内容，只阅读感兴趣的部分。每章的引言都简要介绍了本章要讲解的内容，供读者参考。

本书并不需要读者有任何Redis的背景知识，不过如果读者有Web后端开发经验或Linux系统使用经验，阅读本书将会更加得心应手。

组织结构

第1章介绍了Redis的历史与特性，主要回答两个初学者最关心的问题，即Redis是什么和为什么要使用Redis。

第2章讲解了如何安装和运行Redis。如果你身旁的计算机没有运行Redis，那么一定不要错过这一章，因为本书后面的部分都需要读者最好能一边阅读一边实践，以提高学习效率。本章中还会介绍Redis命令行客户端的使用方法等基础知识，这些都是实践前需要掌握的知识。

第3章介绍了Redis的数据类型。本章讲解的不仅是每个数据类型的介绍和命令的格式，还会着重讲解每个数据类型分别在实践中如何使用。整个第3章会带领读者从零开始，一步步地使用 Redis构建一个博客系统，旨在帮助读者在学习完本章的内容之后可以直接在自己的项目中上手实践Redis。

第4章引入了一些Redis的进阶知识，比如事务和消息系统等。同样本章还会继续以博客系统为例子，以实践驱动学习。

第5章介绍了如何在各个编程语言中使用 Redis，这些语言包括 PHP、Ruby、Python 和Node.js。其中讲解每种语言时最后都会以一个有趣的例子作为演示，即使你不了解某些语言，阅读这些例子也能让你收获颇丰。

第6章展示了Redis脚本的强大功能。本章会向读者讲解如何借助脚本来扩展Redis，并且会对脚本一些需要注意的地方（如沙盒、随机结果等）进行着重介绍。

第7章介绍了运维方面的知识，包括持久化、复制等，并向读者推荐了几个第三方的Redis管理工具。

附录A收录了Redis命令的不同属性，以及属性的特征。

附录B收录了Redis部分配置参数的章节索引。

排版约定

本书排版使用字体遵从以下约定。

●等宽字：表示在命令行中输入的命令以及返回结果、程序代码、Redis的命令（包括命令语句和命令定义）。

●等宽斜体字（或夹在其中的中文楷体字）：表示命令或程序代码中由读者自行替换的参数或变量。

●等宽粗体字：表示命令行中用户的输入内容、伪代码中的Redis命令。

●命令行的输入或输出以如下格式显示：

redis-cli PING

PONG

●程序代码以如下格式显示：

var redis=require("redis");

var client=redis.createClient();

//将两个对象JSON 序列化后存入数据库中

client.mset(

'user:1',JSON.stringify(bob),

'user:2',JSON.stringify(jeff)

);

代码约定

本书的部分章节采用了伪代码来讲解，这种伪代码类似Ruby和PHP，如：

def hsetnx(key,field,value)

isExists=HEXISTS key,field

if isExists is 0

HSET key,field,value

return 1

else

return 0

其中变量使用符号标识，Redis命令使用的粗体表示并省略了括号以便于阅读。在命令调用和print等语句中没有符号的字符串会被当做字符串字面值。

附加文件

本书第5章中每一节都包含了一个完整的程序，通常来讲读者最好自己输入这些代码来加深理解，当然如果要先看到程序的运行结果再开始学习也不失为一个好办法。

这些程序代码都存放在GitHub上（https://github.com/luin/redis-book-assets），可以在GitHub上查看与下载。

致谢

在本书写作的过程中，得到了很多朋友的帮助。请允许我在这里占用少许篇幅，向他们致以诚挚的谢意。

感谢人民邮电出版社的杨海玲老师对本书的支持，没有她的悉心指导，本书就无法顺利完成。

感谢刘亚晨、李欣越、寇祖阳和余尧，他们承担了许多额外的工作，使得我可以全身心地投入到写作中。

感谢所有浏览本书并提供意见和建议的人们：张沈鹏、陈硕实、刘其帅、扈煊、李其超、朱沖宇、王诗吟、黄山月、刘昕、韩重远、李申申、杨海朝、田琪等。感谢你们的支持。

另外还要感谢“宋老师”，是的，就是书中的主人公之一。几年前，我刚创业时，办公场所是和某个教育机构合租的，宋老师是该机构的一名英语老师，同时他也是国内一个知名的嘻哈乐团成员之一。他平日风趣的谈吐带给了我们很多欢乐，伴随我们走过了艰苦的创业初期，而我接触Redis，也正是从这段时间开始的。

最后，感谢我的父母和女朋友马梦妍，你们是我生命中最重要的人，感谢你们的理解和支持。

目录

封面

扉页

版权

前言

第1章 简介

1.1 历史与发展

1.2 特性

1.2.1 存储结构

1.2.2 内存存储与持久化

1.2.3 功能丰富

1.2.4 简单稳定

第2章 准备

2.1 安装Redis

2.1.1 在POSIX系统中安装

2.1.2 在OS X系统中安装

2.1.3 在Windows中安装

2.2 启动和停止Redis

2.2.1 启动Redis

2.2.2 停止Redis

2.3 Redis命令行客户端

2.3.1 发送命令

2.3.2 命令返回值

2.4 配置

2.5 多数据库

第3章 入门

3.1 热身

3.2 字符串类型

3.2.1 介绍

3.2.2 命令

3.2.3 实践

3.2.4 命令拾遗

3.3 散列类型

3.3.1 介绍

3.3.2 命令

3.3.3 实践

3.3.4 命令拾遗

3.4 列表类型

3.4.1 介绍

3.4.2 命令

3.4.3 实践

3.4.5 命令拾遗

3.5 集合类型

3.5.1 介绍

3.5.2 命令

3.5.3 实践

3.5.4 命令拾遗

3.6 有序集合类型

3.6.1 介绍

3.6.2 命令

3.6.3 实践

3.6.4 命令拾遗

第4章 进阶

4.1 事务

4.1.1 概述

4.1.2 错误处理

4.1.3 WATCH命令介绍

4.2 生存时间

4.2.1 命令介绍

4.2.2 实现访问频率限制之一

4.2.3 实现访问频率限制之二

4.2.4 实现缓存

4.3 排序

4.3.1 有序集合的集合操作

4.3.2 SORT命令

4.3.3 BY参数

4.3.4 GET参数

4.3.5 STORE参数

4.3.6 性能优化

4.4 消息通知

4.4.1 任务队列

4.4.2 使用Redis实现任务队列

4.4.3 优先级队列

4.4.4 “发布/订阅”模式

4.4.5 按照规则订阅

4.5 管道

4.6 节省空间

4.6.1 精简键名和键值

4.6.2 内部编码优化

第5章 实践

5.1 PHP与Redis

5.1.1 安装

5.1.2 使用方法

5.1.3 简便用法

5.1.4 实践：用户注册登录功能

5.2 Ruby与Redis

5.2.1 安装

5.2.2 使用方法

5.2.3 简便用法

5.2.4 实践：自动完成

5.3 Python与Redis

5.3.1 安装

5.3.2 使用方法

5.3.3 简便用法

5.3.4 实践：在线的好友

5.4 Node.js与Redis

5.4.1 安装

5.4.2 使用方法

5.4.3 简便用法

5.4.4 实践：IP地址查询

第6章 脚本

6.1 概览

6.1.1 脚本介绍

6.1.2 实例：访问频率限制

6.2 Lua语言

6.2.1 Lua语法

6.2.2 标准库

6.2.3 其他库

6.3 Redis与Lua

6.3.1 在脚本中调用Redis命令

6.3.2 从脚本中返回值

6.3.3 脚本相关命令

6.3.4 应用实例

6.4 深入脚本

6.4.1 KEYS与ARGV

6.4.2 沙盒与随机数

6.4.3 其他脚本相关命令

6.4.4 原子性和执行时间

第7章 管理

7.1 持久化

7.1.1 RDB方式

7.1.2 AOF方式

7.2 复制

7.2.1 配置

7.2.2 原理

7.2.3 图结构

7.2.4 读写分离

7.2.5 从数据库持久化

7.3 安全

7.3.1 可信的环境

7.3.2 数据库密码

7.3.3 命名命令

7.4 通信协议

7.4.1 简单协议

7.4.2 统一请求协议

7.5 管理工具

7.5.1 redis-cli

7.5.2 phpRedisAdmin

7.5.3 Rdbtools

附录A Redis命令属性

A.1 REDIS_CMD_WRITE

A.2 REDIS_CMD_DENYOOM

A.3 REDIS_CMD_NOSCRIPT

A.4 REDIS_CMD_RANDOM

A.5 REDIS_CMD_SORT_FOR_SCRIPT

A.6 REDIS_CMD_LOADING

附录B 配置参数索引

第1章 简介

Redis是一个开源的高性能键值对数据库。它通过提供多种键值数据类型来适应不同场景下的存储需求，并借助许多高层级的接口使其可以胜任如缓存、队列系统等不同的角色。

本章将分别介绍Redis的历史和特性，以使读者能够快速地对Redis有一个全面的了解。

1.1 历史与发展

2008年，意大利的一家创业公司Merzia.① 推出了一款基于MySQL的网站实时统计系统LLOOGG② ，然而没过多久该公司的创始人Salvatore Sanfilippo便开始对MySQL的性能感到失望，于是他决定亲自为LLOOGG量身定做一个数据库，并于2009年开发完成，这个数据库就是Redis。不过Salvatore Sanfilippo并不满足只将Redis用于LLOOGG这一款产品，而是希望让更多的人使用它，于是在同一年Salvatore Sanfilippo将Redis开源发布，并开始和Redis的另一名主要的代码贡献者Pieter Noordhuis一起继续着Redis的开发，直到今天。

注释：①见http://merzia.com。

注释：②见http://lloogg.com。

Salvatore Sanfilippo自己也没有想到，短短的几年时间，Redis就拥有了庞大的用户群体。Hacker News在2012年发布了一份数据库的使用情况调查③ ，结果显示有近12%的公司在使用Redis。国内如新浪微博、街旁和知乎，国外如GitHub、Stack Overflow、Flickr、暴雪和Instagram，都是Redis的用户。

注释：③见http://news.ycombinator.com/item?id=4833188。

VMware公司从2010年开始赞助Redis的开发，Salvatore Sanfilippo和Pieter Noordhuis也分别于同年的3月和5月加入VMware，全职开发Redis。

Redis的代码托管在GitHub上，开发十分活跃（见图1-1）。截至交稿时，Redis的最新版本是2.6.9。

图1-1 antirez是Salvatore Sanfilippo的GitHub用户名，截图的前两天他还提交过代码

1.2 特性

作为一款个人开发的数据库，Redis究竟有什么魅力吸引了如此多的用户呢？

1.2.1 存储结构

有过脚本语言编程经验的读者对字典（或称映射、关联数组）数据结构一定很熟悉，如代码dict["key"]="value"中dict是一个字典结构变量，字符串"key"是键名，而"value"是键值，在字典中我们可以获取或设置键名对应的键值，也可以删除一个键。

Redis是REmote DIctionary Server（远程字典服务器）的缩写，它以字典结构存储数据，并允许其他应用通过TCP协议读写字典中的内容。同大多数脚本语言中的字典一样，Redis字典中的键值除了可以是字符串，还可以是其他数据类型。到目前为止Redis支持的键值数据类型如下：

●字符串类型

●散列类型

●列表类型

●集合类型

●有序集合类型

这种字典形式的存储结构与常见的MySQL 等关系数据库的二维表形式的存储结构有很大的差异。举个例子，如下所示，我们在程序中使用post变量存储了一篇文章的数据（包括标题、正文、阅读量和标签）：

post["title"]="Hello World!"

post["content"]="Blablabla..."

post["views"]=0

post["tags"]=["PHP","Ruby","Node.js"]

现在我们希望将这篇文章的数据存储在数据库中，并且要求可以通过标签检索出文章。如果使用关系数据库存储，一般会将其中的标题、正文和阅读量存储在一个表中，而将标签存储在另一个表中，然后使用第三个表连接文章和标签表① 。需要查询时还得将三个表进行连接，不是很直观。而Redis字典结构的存储方式和对多种键值数据类型的支持使得开发者可以将程序中的数据直接映射到Redis中，数据在Redis中的存储形式和其在程序中的存储方式非常相近。使用Redis的另一个优势是其对不同的数据类型提供了非常方便的操作方式，如使用集合类型存储文章标签，Redis可以对标签进行如交集、并集这样的集合运算操作。3.5节会专门介绍如何借助集合运算轻易地实现“找出所有同时属于A标签和B标签且不属于C标签”这样关系数据库实现起来性能不高且较为繁琐的操作。

注释：①这是一种符合第三范式的设计。事实上还可以使用其他方式来实现标签系统，参阅http://tagging.pui.-ch/-post/37027745720/tags-database-schemas以了解更多相关资料。

1.2.2 内存存储与持久化

Redis数据库中的所有数据都存储在内存中。由于内存的读写速度远快于硬盘，因此Redis在性能上对比其他基于硬盘存储的数据库有非常明显的优势，在一台普通的笔记本电脑上，Redis可以在一秒内读写超过十万个键值。

将数据存储在内存中也有问题，例如，程序退出后内存中的数据会丢失。不过 Redis提供了对持久化的支持，即将可以内存中的数据异步写入到硬盘中，同时不影响继续提供服务。

1.2.3 功能丰富

Redis虽然是作为数据库开发的，但由于其提供了丰富的功能，越来越多的人将其用作缓存、队列系统等。Redis可谓是名副其实的多面手。

Redis可以为每个键设置生存时间（Time To Live，TTL），生存时间到期后键会自动被删除。这一功能配合出色的性能让Redis可以作为缓存系统来使用，而且由于Redis支持持久化和丰富的数据类型，使其成为了另一个非常流行的缓存系统Memcached的有力竞争者。

讨论  关于Redis和Memcached优劣的讨论一直是一个热门的话题。在性能上Redis是单线程模型，而Memcached支持多线程，所以在多核服务器上后者的性能更高一些。然而，前面已经介绍过，Redis的性能已经足够优异，在绝大部分场合下其性能都不会成为瓶颈。所以在使用时更应该关心的是二者在功能上的区别，如果需要用到高级的数据类型或是持久化等功能，Redis将会是Memcached很好的替代品。

作为缓存系统，Redis还可以限定数据占用的最大内存空间，在数据达到空间限制后可以按照一定的规则自动淘汰不需要的键。

除此之外，Redis的列表类型键可以用来实现队列，并且支持阻塞式读取，可以很容易地实现一个高性能的优先级队列。同时在更高层面上，Redis还支持“发布/订阅”的消息模式，可以基于此构建聊天室① 等系统。

注释：①Redis的贡献者之一Pieter Noordhuis提供了一个使用该模式开发的聊天室的例子，见https://gist.github.com/348262。

1.2.4 简单稳定

即使功能再丰富，如果使用起来太复杂也很难吸引人。Redis直观的存储结构使得通过程序与Redis交互十分简单。在Redis中使用命令来读写数据，命令语句之于Redis就相当于SQL语言之于关系数据库。例如在关系数据库中要获取posts表内id为1的记录的title字段的值可以使用如下SQL语句实现：

SELECT title FROM posts WHERE id=1 LIMIT 1

相对应的，在Redis中要读取键名为post:1的散列类型键的title字段的值，可以使用如下命令语句实现：

HGET post:1 title

其中HGET就是一个命令。Redis提供了一百多个命令（如图1-2所示），听起来很多，但是常用的却只有十几个，并且每个命令都很容易记忆。读完第3章你就会发现Redis的命令比SQL语言要简单很多。

图1-2 Redis官网提供了详细的命令文档

Redis提供了几十种不同编程语言的客户端库，这些库都很好地封装了Redis的命令，使得在程序中与Redis进行交互变得更容易。有些库还提供了可以将编程语言中的数据类型直接以相应的形式存储到Redis中（如将数组直接以列表类型存入Redis）的简单方法，使用起来非常方便。

Redis使用C语言开发，代码量只有3万多行。这降低了用户通过修改Redis源代码来使之更适合自己项目需要的门槛。对于希望“榨干”数据库性能的开发者而言，这无疑是一个很大的吸引力。

Redis 是开源的，所以事实上Redis的开发者并不止Salvatore Sanfilippo和Pieter Noordhuis。截至目前，有将近100名开发者为Redis贡献了代码。良好的开发氛围和严谨的版本发布机制使得Redis的稳定版本非常可靠，如此多的公司在项目中使用了Redis也可以印证这一点。

第2章 准备

“纸上得来终觉浅，绝知此事要躬行。”

——陆游《冬夜读书示子聿》

学习Redis最好的办法就是动手尝试它。在介绍Redis最核心的内容之前，本章先来介绍一下如何安装和运行Redis，以及Redis的基础知识，使读者可以在之后的章节中一边学习一边实践。

2.1 安装Redis

安装Redis是开始Redis学习之旅的第一步。在安装Redis前需要了解Redis的版本规则以选择最适合自己的版本，Redis约定次版本号（即第一个小数点后的数字）为偶数的版本是稳定版（如2.4版、2.6版），奇数版本是非稳定版（如2.5版、2.7版），推荐使用稳定版本进行开发和在生产环境使用。

2.1.1 在POSIX系统中安装

Redis兼容大部分POSIX系统，包括Linux、OS X和BSD等，在这些系统中推荐直接下载Redis源代码编译安装以获得最新的稳定版本。Redis最新稳定版本的源代码可以从地址http://download.redis.io/redis-stable.tar.gz下载。

下载安装包后解压即可使用make 命令完成编译，完整的命令如下：

wget http://download.redis.io/redis-stable.tar.gz

tar xzf redis-stable.tar.gz

cd redis-stable

make

Redis没有其他外部依赖，安装过程很简单。编译后在Redis源代码目录的src文件夹中可以找到若干个可执行程序，最好在编译后直接执行make install命令来将这些可执行程序复制到/usr/local/bin目录中以便以后执行程序时可以不用输入完整的路径。

在实际运行Redis前推荐使用make test命令测试Redis是否编译正确，尤其是在编译一个不稳定版本的Redis时。

提示  除了手工编译外，还可以使用操作系统中的软件包管理器来安装Redis，但目前大多数软件包管理器中的Redis的版本都较古老。考虑到Redis的每次升级都提供了对以往版本的问题修复和性能提升，使用最新版本的Redis往往可以提供更加稳定的体验。如果希望享受包管理器带来的便利，在安装前请确认您使用的软件包管理器中Redis的版本并了解该版本与最新版之间的差异。http://redis.io/topics/problems中列举了一些在以往版本中存在的已知问题。

2.1.2 在OS X系统中安装

OS X下的软件包管理工具Homebrew和MacPorts均提供了较新版本的Redis包，所以我们可以直接使用它们来安装Redis，省去了像其他POSIX系统那样需要手动编译的麻烦。下面以使用Homwbrew安装Redis为例。

1．安装Homebrew

在终端下输入ruby -e "(curl -fsSkL raw.github.com/mxcl/homebrew/go)"即可安装Homebrew。

如果之前安装过Homebrew，请执行brew update来更新Homebrew，以便安装较新版的Redis。

2．通过Homebrew安装Redis

使用brew install软件包名可以安装相应的包，此处执行brew install redis来安装Redis：

brew install redis

==>Downloading http://redis.googlecode.com/files/redis-2.6.9.tar.gz

Already downloaded:/Library/Caches/Homebrew/redis-2.6.9.tar.gz

==>make -C/private/tmp/redis-OV9u/redis-2.6.9/src CC=cc

==>Caveats

To have launchd start redis at login:

ln -sfv/usr/local/opt/redis/＊.plist～/Library/LaunchAgents

Then to load redis now:

launchctl load～/Library/LaunchAgents/homebrew.mxcl.redis.plist

Or,if you don't want/need launchctl,you can just run:

redis-server /usr/local/etc/redis.conf

/usr/local/Cellar/redis/2.6.9:9 files,740K,built in 6 seconds

OSX 系统从Tiger版本开始引入了launchd工具来管理后台程序，如果想让Redis随系统自动运行可以通过以下命令配置launchd：

ln -sfv/usr/local/opt/redis/＊.plist～/Library/LaunchAgents

launchctl load～/Library/LaunchAgents/homebrew.mxcl.redis.plist

通过launchd运行的Redis会加载位于/usr/local/etc/redis.conf的配置文件，关于配置文件会在2.4节中介绍。

2.1.3 在Windows中安装

Redis官方不支持Windows。2011年微软① 向Redis提交了一个补丁，以使Redis可以在Windows下编译运行，但被Salvatore Sanfilippo拒绝了，原因是在服务器领域上Linux已经得到了广泛的使用，让Redis能在Windows下运行相比而言显得不那么重要。并且Redis使用了如写时复制等很多操作系统相关的特性，兼容Windows会耗费太大的精力而影响Redis其他功能的开发。尽管如此微软还是发布了一个可以在Windows运行的Redis分支② ，但是考虑到其版本更新速度比较慢（截至本书交稿，其最新的版本是基于Redis 2.4进行开发的），并不建议使用。

注释：①微软开放技术有限公司（Microsoft Open Technologies Inc.），专注于参与开源项目、开放标准工作组以及提出倡议。

注释：②见https://github.com/M SOpenTech/Redis。

如果想使用Windows学习或测试Redis可以通过Cygwin软件或虚拟机（如VirtualBox）来完成。Cygwin能够在Windows中模拟Linux系统环境。Cygwin实现了一个Linux API接口，使得大部分Linux下的软件可以重新编译后在Windows下运行。Cygwin还提供了自己的软件包管理工具，让用户能够方便地安装和升级几千个软件包。借助Cygwin，我们可以在Windows上通过源代码编译安装最新版的Redis。

1．安装Cygwin

从Cygwin官方网站（http://cygwin.com）下载setup.exe程序，setup.exe既是Cygwin的安装包，又是Cygwin的软件包管理器。运行setup.exe后进入安装向导。前几步会要求选择下载源、安装路径、代理和下载镜像等，可以根据具体需求选择，一般来说一路单击“Next”即可。之后会出现软件包管理界面，如图2-1所示。

图2-1 Cygwin包管理界面

编译安装Redis需要用到的包有gcc和make，二者都可以在“Devel”分类中找到。在“New”字段中标记为“Skip”的包表示不安装，单击“Skip”切换成需要安装的版本号即可令Cygwin在稍后安装该版本的包。图2-1中所示gcc包的状态为“Keep”是因为作者之前已经安装过该包了，同样如果读者在退出安装向导后还想安装其他软件包，只需要重新运行setup.exe程序再次进入此界面即可。

为了方便使用，我们还可以安装wget（用于下载Redis源代码，也可以手动下载并使用Windows资源管理器将其复制到Cygwin对应的目录中，见下文介绍）和vim（用于修改Redis的源代码使之可以在Cygwin下正常编译）。

之后单击下一步，安装向导就会自动完成下载和安装工作了。

安装成功后打开Cygwin Terminal程序即可进入Cygwin环境，Cygwin会将Windows中的目录映射到Cygwin中。如果安装时没有更改安装目录，Cygwin环境中的根目录对应的Windows中的目录是C:\cygwin。

2．修改Redis源代码

下载和解压Redis的过程和2.1.1节中介绍的一样，不过在make之前还需要修改Redis的源代码以使其可以在Cygwin下正常编译。

首先编辑src目录下的redis.h文件，在头部加入：

#ifndef SA_ONSTACK

#define SA_ONSTACK 0

#endif

而后编辑src目录下的object.c文件，在头部加入：

#define strtold(a,b) ((long double)strtod((a),(b)))

3．编译Redis

同2.1.1节一样，执行make命令即可完成编译。

注意  Cygwin环境无法完全模拟Linux系统，比如Cygwin的fork不支持写时复制；另外，Redis官方也并不提供对Cygwin的支持，Cygwin环境只能用于学习Redis，运行Redis的最佳系统是Linux和OSX，官方推荐的生产系统是Linux。

2.2 启动和停止Redis

安装完Redis后的下一步就是启动它，本节将分别介绍在开发环境和生产环境中运行Redis的方法以及正确停止Redis的步骤。

在这之前首先需要了解Redis包含的可执行文件都有哪些，表2-1中列出了这些程序的名称以及对应的说明。如果在编译后执行了make install命令，这些程序会被复制到/usr/local/bin目录内，所以在命令行中直接输入程序名称即可执行。

表2-1  Redis可执行文件说明

我们最常使用的两个程序是redis-server和redis-cli，其中redis-server是Redis的服务器，启动Redis即运行redis-server；而redis-cli是Redis自带的Redis命令行客户端，是学习Redis的重要工具，2.3节会详细介绍它。

2.2.1 启动Redis

启动Redis有直接启动和通过初始化脚本启动两种方式，分别适用于开发环境和生产环境。

1．直接启动

直接运行redis-server即可启动Redis，十分简单：

redis-server

[5101]14 Dec 20:58:59.944 # Warning:no config file specified,using the default

config.In order to specify a config file use redis-server/path/to/redis.conf

[5101]14 Dec 20:58:59.948 ＊ Max number of open files set to 10032

...

[5101]14 Dec 20:58:59.949 # Server started,Redis version 2.6.9

[5101]14 Dec 20:58:59.949 ＊ The server is now ready to accept connections on port 6379

Redis服务器默认会使用6379端口① ，通过--port参数可以自定义端口号：

注释：①6379是手机键盘上MERZ对应的数字，MERZ 是一名意大利歌女的名字。

redis-server --port 6380

2．通过初始化脚本启动Redis

在Linux系统中可以通过初始化脚本启动Redis，使得Redis能随系统自动运行，在生产环境中推荐使用此方法运行Redis，这里以Ubuntu和Debian发行版为例进行介绍。在Redis源代码目录的utils文件夹中有一个名为redis_init_script的初始化脚本文件，内容如下：

#!/bin/sh

#

#Simple Redis init.d script conceived to work on Linux systems

#as it does use of the /proc filesystem.

REDISPORT=6379

EXEC=/usr/local/bin/redis-server

CLIEXEC=/usr/local/bin/redis-cli

PIDFILE=/var/run/redis_{REDISPORT}.pid

CONF="/etc/redis/{REDISPORT}.conf"

case "1" in

start)

if [-f PIDFILE]

then

echo "PIDFILE exists, process is already running or crashed"

else

echo"Starting Redis server..."

EXEC CONF

fi

;;

stop)

if [ ! -f PIDFILE ]

then

echo "PIDFILE does not exist, process is not running"

else

PID=(cat PIDFILE)

echo "Stopping ..."

CLIEXEC -p REDISPORT shutdown

while [-x /proc/{PID}]

do

echo "Waiting for Redis to shutdown ..."

sleep 1

done

echo "Redis stopped"

fi

;;

＊)

echo "Please use start or stop as first argument"

;;

esac

我们需要配置Redis的运行方式和持久化文件、日志文件的存储位置等，具体步骤如下。

（1）配置初始化脚本。首先将初始化脚本复制到/etc/init.d目录中，文件名为redis_端口号，其中端口号表示要让Redis监听的端口号，客户端通过该端口连接Redis。然后修改脚本第6行的REDISPORT变量的值为同样的端口号。

（2）建立需要的文件夹。建立表2-2中列出的目录。

表2-2  需要建立的目录及说明

（3）修改配置文件。首先将配置文件模板（见 2.4 节介绍）复制到/etc/redis目录中，以端口号命名（如“6379.conf”），然后按照表2-3对其中的部分参数进行编辑。

表2-3  需要修改的配置及说明

现在就可以使用/etc/init.d/redis_端口号start来启动Redis了，而后需要执行下面的命令使Redis随系统自动启动：

sudo update-rc.d redis_端口号defaults

2.2.2 停止Redis

考虑到Redis有可能正在将内存中的数据同步到硬盘中，强行终止Redis进程可能会导致数据丢失。正确停止Redis的方式应该是向Redis发送SHUTDOWN命令，方法为：

redis-cli SHUTDOWN

当Redis收到SHUTDOWN命令后，会先断开所有客户端连接，然后根据配置执行持久化，最后完成退出。

Redis可以妥善处理SIGTERM信号，所以使用“kill Redis进程的PID”也可以正常结束Redis，效果与发送SHUTDOWN命令一样。

2.3 Redis命令行客户端

还记得我们刚才编译出来的redis-cli程序吗？redis-cli（Redis Command Line Interface）是Redis自带的基于命令行的Redis客户端，也是我们学习和测试Redis的重要工具，本书后面会使用它来讲解Redis各种命令的用法。

本节将会介绍如何通过redis-cli向Redis发送命令，并且对Redis命令返回值的不同类型进行简单介绍。

2.3.1 发送命令

通过 redis-cli向Redis发送命令有两种方式，第一种方式是将命令作为redis-cli的参数执行，比如在2.2.2节中用过的redis-cli SHUTDOWN。redis-cli执行时会自动按照默认配置（服务器地址为127.0.0.1，端口号为6379）连接Redis，通过-h和-p参数可以自定义地址和端口号：

redis-cli -h 127.0.0.1 -p 6379

Redis提供了PING命令来测试客户端与Redis的连接是否正常，如果连接正常会收到回复PONG。如：

redis-cli PING

PONG

第二种方式是不附带参数运行redis-cli，这样会进入交互模式，可以自由输入命令，例如：

redis-cli

redis 127.0.0.1:6379> PING

PONG

redis 127.0.0.1:6379> ECHO hi

"hi"

这种方式在要输入多条命令时比较方便，也是本书中主要采用的方式。为了简便起见，后文中我们将用redis>表示redis 127.0.0.1:6379>。

2.3.2 命令返回值

在大多数情况下，执行一条命令后我们往往会关心命令的返回值，如1.2.4节中的HGET命令的返回值就是我们需要的指定键的title字段的值。命令的返回值有5种类型，对于每种类型redis-cli的展现结果都不同，下面分别说明。

1．状态回复

状态回复（status reply）是最简单的一种回复，比如向Redis发送SET命令设置某个键的值时，Redis会回复状态OK表示设置成功。另外之前演示的对PING命令的回复PONG也是状态回复。状态回复直接显示状态信息，例如：

redis>PING

PONG

2．错误回复

当出现命令不存在或命令格式有错误等情况时Redis会返回错误回复（error reply）。错误回复以(error)开头，并在后面跟上错误信息。如执行一个不存在的命令：

redis>ERRORCOMMEND

(error) ERR unknown command 'ERRORCOMMEND'

3．整数回复

Redis虽然没有整数类型，但是却提供了一些用于整数操作的命令，如递增键值的INCR命令会以整数形式返回递增后的键值。除此之外，一些其他命令也会返回整数，如可以获取当前数据库中键的数量的DBSIZE命令等。整数回复（integer reply）以(integer)开头，并在后面跟上整数数据：

redis>INCR foo

(integer) 1

4．字符串回复

字符串回复（bulk reply）是最常见的一种回复类型，当请求一个字符串类型键的键值或一个其他类型键中的某个元素时就会得到一个字符串回复。字符串回复以双引号包裹：

redis>GET foo

"1"

特殊情况是当请求的键值不存在时会得到一个空结果，显示为(nil)。如：

redis>GET noexists

(nil)

5．多行字符串回复

多行字符串回复（multi-bulk reply）同样很常见，如当请求一个非字符串类型键的元素列表时就会收到多行字符串回复。多行字符串回复中的每行字符串都以一个序号开头，如：

redis> KEYS ＊

1) "bar"

2) "foo"

提示  KEYS命令的作用是获取数据库中符合指定规则的键名，由于读者的Redis中还没有存储数据，所以得到的返回值应该是（empty list or set）。3.1节会具体介绍KEYS命令，此处读者只需了解多行字符串回复的格式即可。

2.4 配置

2.2.1节中我们通过redis-server的启动参数port设置了Redis的端口号，除此之外Redis还支持其他配置选项，如是否开启持久化、日志级别等。由于可以配置的选项较多，通过启动参数设置这些选项并不方便，所以Redis支持通过配置文件来设置这些选项。启用配置文件的方法是在启动时将配置文件的路径作为启动参数传递给redis-server，如：

redis-server/path/to/redis.conf

通过启动参数传递同名的配置选项会覆盖配置文件中相应的参数，就像这样：

redis-server/path/to/redis.conf --loglevel warning

Redis提供了一个配置文件的模板redis.conf，位于源代码目录的根目录中。

除此之外还可以在Redis运行时通过CONFIG SET 命令在不重新启动Redis的情况下动态修改部分Redis配置。就像这样：

redis>CONFIG SET loglevel warning

OK

并不是所有的配置都可以使用CONFIG SET命令修改，附录B列出了哪些配置能够使用该命令修改。同样在运行的时候也可以使用CONFIG GET命令获得Redis当前的配置情况，例如：

redis>CONFIG GET loglevel

1) "loglevel"

2) "warning"

其中第一行字符串回复表示的是选项名，第二行即是选项值。

2.5 多数据库

第1章介绍过Redis是一个字典结构的存储服务器，而实际上一个Redis实例提供了多个用来存储数据的字典，客户端可以指定将数据存储在哪个字典中。这与我们熟知的在一个关系数据库实例中可以创建多个数据库类似，所以可以将其中的每个字典都理解成一个独立的数据库。

每个数据库对外都是以一个从0开始的递增数字命名，Redis默认支持16个数据库，可以通过配置参数databases来修改这一数字。客户端与Redis建立连接后会自动选择0号数据库，不过可以随时使用SELECT命令更换数据库，如要选择1号数据库：

redis>SELECT 1

OK

redis [1]>GET foo

(nil)

然而这些以数字命名的数据库又与我们理解的数据库有所区别。首先Redis不支持自定义数据库的名字，每个数据库都以编号命名，开发者必须自己记录哪些数据库存储了哪些数据。另外Redis也不支持为每个数据库设置不同的访问密码，所以一个客户端要么可以访问全部数据库，要么连一个数据库也没有权限访问。最重要的一点是多个数据库之间并不是完全隔离的，比如FLUSHALL命令可以清空一个Redis实例中所有数据库中的数据。综上所述，这些数据库更像是一种命名空间，而不适宜存储不同应用程序的数据。比如可以使用0号数据库存储某个应用生产环境中的数据，使用1号数据库存储测试环境中的数据，但不适宜使用0号数据库存储A应用的数据而使用1号数据库存储B应用的数据，不同的应用应该使用不同的Redis实例存储数据。由于Redis非常轻量级，一个空Redis实例占用的内存只有1MB左右，所以不用担心多个Redis实例会额外占用很多内存。

第3章 入门

学会了如何安装和运行Redis，并了解了Redis的基础知识后，本章将详细介绍Redis的五种数据类型及相应的命令，带领读者真正进入Redis的世界。在学习的时候，手边打开一个redis-cli程序来跟着一起输入命令将会极大地提高学习效率。

在之后的章节中你会遇到两个学习伙伴：小白和宋老师。小白是一个标准的极客，最近刚开始他的Redis学习之旅，而他大学时的计算机老师宋老师恰好对 Redis颇有研究，于是就顺理成章地成为了小白的私人Redis教师。这不，小白想基于Redis开发一个博客，于是找到宋老师，向他请教。在本章中宋老师会向小白介绍Redis最核心的内容——数据类型，从他们的对话中你一定能学到不少知识！

3.2节到3.6节这5节将分别介绍Redis的5种数据类型，其中每节都是由4个部分组成，依次是“介绍”、“命令”、“实践”和“命令拾遗”。“介绍”部分是对数据类型的概述，“命令”部分会对“实践”部分将用到的命令进行介绍，“实践”部分会讲解该数据类型在开发中的应用方法，“命令拾遗”部分会对该数据类型其他比较有用的命令进行补充介绍。

3.1 热身

在介绍 Redis的数据类型之前，我们先来了解几个比较基础的命令作为热身，赶快打开redis-cli，跟着样例亲自输入命令来体验一下吧！

1．获得符合规则的键名列表

KEYS pattern

pattern支持glob风格通配符格式，具体规则如表3-1所示。

表3-1  glob 风格通配符规则

现在Redis中空空如也（如果你从第2章开始就一直跟着本书的进度输入命令，此时数据库中可能还会有个foo键），为了演示KEYS命令，首先我们得给Redis加点料。使用SET命令（会在3.2节介绍）建立一个名为bar的键：

redis＞SET bar 1

OK

然后使用KEYS ＊就能获得Redis中所有的键了（当然由于数据库中只有一个bar键，所以KEYS ba＊或者KEYS bar 等命令都能获得同样的结果）：

redis＞ KEYS ＊

1) "bar"

注意  KEYS命令需要遍历Redis中的所有键，当键的数量较多时会影响性能，不建议在生产环境中使用。

提示  Redis不区分命令大小写，但在本书中均会使用大写字母表示Redis命令。

2．判断一个键是否存在

EXISTS key

如果键存在则返回整数类型1，否则返回0。如：

redis＞EXISTS bar

(integer) 1

redis＞EXISTS noexists

(integer)0

3．删除键

DEL key [key …]

可以删除一个或多个键，返回值是删除的键的个数。如：

redis＞DEL bar

(integer) 1

redis＞DEL bar

(integer) 0

第二次执行DEL命令时因为bar键已经被删除了，实际上并没有删除任何键，所以返回0。

技巧  DEL 命令的参数不支持通配符，但我们可以结合Linux 的管道和xargs 命令自己实现删除所有符合规则的键。比如要删除所有以“user:”开头的键，就可以执行redis-cli KEYS "user:*" | xargs redis-cli DEL。另外由于DEL 命令支持多个键作为参数， 所以还可以执行redis-cli DEL 'redis-cli KEYS"user:*"'来达到同样的效果，但是性能更好。

4．获得键值的数据类型

TYPE key

TYPE命令用来获得键值的数据类型，返回值可能是string（字符串类型）、hash（散列类型）、list（列表类型）、set（集合类型）、zset（有序集合类型）。例如：

redis＞SET foo 1

OK

redis＞TYPE foo

string

redis＞LPUSH bar 1

(integer) 1

redis＞TYPE bar

list

LPUSH命令的作用是向指定的列表类型键中增加一个元素，如果键不存在则创建它，3.4节会详细介绍。

3.2 字符串类型

作为一个爱造轮子的资深极客，小白每次看到自己博客最下面的“Powered byW ordPress”① 都觉得有些不舒服，终于有一天他下定决心要开发一个属于自己的博客。但是用腻了MySQL数据库的小白总想尝试一下新技术，恰好上次参加Node Party时听人介绍过Redis数据库，便想着趁机试一试。可小白只知道Redis是一个键值对数据库，其他的一概不知。抱着试一试的态度，小白找到了自己大学时教计算机的宋老师，一问之下欣喜地发现宋老师竟然对Redis颇有研究。宋老师有感于小白的好学，决定给小白开个小灶。

注释：①即“由WordPress驱动”。WordPress是一个开源的博客程序，用户可以借其通过简单的配置搭建一个博客或内容管理系统。

小白：

宋老师您好，我最近听别人介绍过Redis，当时就对它很感兴趣。恰好最近想开发一个博客，准备尝试一下它。有什么能快速学会Redis的方法吗？

宋老师笑着说：

心急吃不了热豆腐，要学会Redis就要先掌握Redis的键值数据类型和相关的命令。Redis不仅支持多种数据类型，而且还为每种数据类型提供了丰富实用的命令。作为开始，我先来讲讲Redis中最基本的数据类型——字符串类型。

3.2.1 介绍

字符串类型是Redis中最基本的数据类型，它能存储任何形式的字符串，包括二进制数据。你可以用其存储用户的邮箱、JSON化的对象甚至是一张图片。一个字符串类型键允许存储的数据的最大容量是512MB① 。

注释：①在Redis 3.0版本中可能会放宽这一限制，但无论如何，考虑到Redis的数据是使用内存存储的，512MB的限制已经非常宽松了。

字符串类型是其他4种数据类型的基础，其他数据类型和字符串类型的差别从某种角度来说只是组织字符串的形式不同。例如，列表类型是以列表的形式组织字符串，而集合类型是以集合的形式组织字符串。学习过本章后面几节后相信读者对此会有更深的理解。

3.2.2 命令

1．赋值与取值

SET key value

GET key

SET和GET是Redis中最简单的两个命令，它们实现的功能和编程语言中的读写变量相似，如key="hello"在Redis中是这样表示的：

redis＞SET key hello

OK

想要读取键值则更简单：

redis＞GET key

"hello"

当键不存在时会返回空结果。

为了节约篇幅，同时避免读者过早地被编程语言的细节困扰，本书大部分章节将只使用redis-cli进行命令演示（必要的时候会配合伪代码），第5章会专门介绍在各种编程语言（PHP、Python、Ruby和Node.js）中使用Redis的方法。

不过，为了能让读者提前对Redis命令在实际开发时的用法有一个直观的体会，这里会先使用PHP实现一个SET/GET命令的示例网页：用户访问示例网页时程序会通过GET命令判断Redis中是否存储了用户的姓名，如果有则直接将姓名显示出来（如图3-1所示），如果没有则会提示用户填写（如图3-2 所示），用户单击“提交”按钮后程序会使用SET命令将用户的姓名存入到Redis中。

图3-1 设置过姓名时的页面

图3-2 没有设置过姓名时的页面

代码如下：

＜?php

//加载Predis库的自动加载函数

require './predis/autoload.php';

//连接Redis

redis=new Predis\Client(array(

'host'＜para＞=＞'127.0.0.1',

'port'＜para＞=＞6379

));

//如果提交了姓名则使用SET命令将姓名写入到Redis中

if (_GET['name']) {

redis-＞set('name',_GET['name']);

}

//通过GET命令从Redis中读取姓名

name=redis-＞get('name');

?＞＜!DOCTYPE html＞

＜html＞

＜head＞

＜meta charset="utf-8" /＞

＜title＞我的第一个Redis程序＜/title＞

＜/head＞

＜body＞

＜?php if (name): ?＞

＜p＞您的姓名是：＜?php echo name; ?＞＜/p＞

＜?php else: ?＞

＜p＞您还没有设置姓名。＜/p＞

＜?php endif; ?＞

＜hr/＞

＜h1＞更改姓名＜/h1＞

＜form＞

＜p＞

＜label for="name"＞您的姓名：＜/label＞

＜input type="text" name="name" id="name" /＞

＜/p＞

＜p＞

＜button type="submit"＞提交＜/button＞

＜/p＞

＜/form＞

＜/body＞

＜/html＞

在这个例子中我们使用PHP的Redis客户端库Predis与Redis通信。5.1节会专门介绍Predis，有兴趣的读者可以先跳到5.1节查看Predis的安装方法来实际运行这个例子。

Redis的其他命令也可以使用Predis通过同样的方式调用，如马上要介绍的INCR 命令的调用方法是redis-＞incr(键名)。

2．递增数字

INCR key

前面说过字符串类型可以存储任何形式的字符串，当存储的字符串是整数形式时，Redis提供了一个实用的命令INCR，其作用是让当前键值递增，并返回递增后的值，用法为：

redis＞INCR num

(integer) 1

redis＞INCR num

(integer) 2

当要操作的键不存在时会默认键值为0，所以第一次递增后的结果是1。当键值不是整数时Redis会提示错误：

redis＞SET foo lorem

OK

redis＞INCR foo

(error) ERR value is not an integer or out of range

有些读者会想到可以借助GET和SET两个命令自己实现incr函数，伪代码如下：

def incr(key)

value=GET key

if not value

value=0

value=value+1

SET key, value

return value

如果Redis同时只连接了一个客户端，那么上面的代码没有任何问题（其实还没有加入错误处理，不过这并不是此处讨论的重点）。可当同一时间有多个客户端连接到Redis时则有可能出现竞态条件（race condition）① 。例如，有两个客户端A和B都要执行我们自己实现的incr函数并准备将同一个键的键值递增，当它们恰好同时执行到代码第二行时二者读取到的键值是一样的，如“5”，而后它们各自将该值递增到“6”并使用SET命令将其赋给原键，结果虽然对键执行了两次递增操作，最终的键值却是“6”而不是预想中的“7”。包括INCR在内的所有Redis命令都是原子操作（atomic operation）② ，无论多少个客户端同时连接，都不会出现上述情况。之后我们还会介绍利用事务（4.1 节）和脚本（第6章）实现自定义的原子操作的方法。

注释：①竞态条件是指一个系统或者进程的输出，依赖于不受控制的事件的出现顺序或者出现时机。

注释：②原子操作取“原子”的“不可拆分”的意思，原子操作是最小的执行单位，不会在执行的过程中被其他命令插入打断。

3.2.3 实践

1．文章访问量统计

博客的一个常见的功能是统计文章的访问量，我们可以为每篇文章使用一个名为post:文章ID:page.view的键来记录文章的访问量，每次访问文章的时候使用INCR命令使相应的键值递增。

提示  Redis对于键的命名并没有强制的要求，但比较好的实践是用“对象类型:对象ID:对象属性”来命名一个键，如使用键user:1:friends来存储ID为1的用户的好友列表。对于多个单词则推荐使用“.”分隔，一方面是沿用以前的习惯（Redis以前版本的键名不能包含空格等特殊字符），另一方面是在redis-cli中容易输入，无需使用双引号包裹。另外为了日后维护方便，键的命名一定要有意义，如u:1:f的可读性显然不如user:1:friends好（虽然采用较短的名称可以节省存储空间，但由于键值的长度往往远远大于键名的长度，所以这部分的节省大部分情况下并不如可读性来得重要）。

2．生成自增ID

那么怎么为每篇文章生成一个唯一ID呢？在关系数据库中我们通过设置字段属性为AUTO_INCREMENT来实现每增加一条记录自动为其生成一个唯一的递增ID的目的，而在Redis中可以通过另一种模式来实现：对于每一类对象使用名为对象类型(复数形式):count① 的键（如users:count）来存储当前类型对象的数量，每增加一个新对象时都使用INCR命令递增该键的值。由于使用INCR命令建立的键的初始键值是1，所以可以很容易得知，INCR命令的返回值既是加入该对象后的当前类型的对象总数，又是该新增对象的ID。

注释：①这个键名只是参考命名，实际使用中可以使用任何容易理解的名称。

3．存储文章数据

由于每个字符串类型键只能存储一个字符串，而一篇博客文章是由标题、正文、作者与发布时间等多个元素构成的。为了存储这些元素，我们需要使用序列化函数（如PHP中的serialize和JavaScript中的JSON.stringify）将它们转换成一个字符串。除此之外因为字符串类型键可以存储二进制数据，所以也可以使用MessagePack② 进行序列化，速度更快，占用空间也更小。

注释：②MessagePack和JSON一样可以将对象序列化成字符串，但其性能更高，序列化后的结果占用空间更小，序列化后的结果是二进制格式。MessagePack的项目地址是http://msgpack.org。

至此我们已经可以写出发布新文章时与Redis操作相关的伪代码了：

#首先获得新文章的ID

postID=INCR posts:count

#将博客文章的诸多元素序列化成字符串

serializedPost=serialize(title,content,author,time)

#把序列化后的字符串存一个入字符串类型的键中

SET post:postID:data,serializedPost

获取文章数据的伪代码如下（以访问ID 为42的文章为例）：

#从Redis 中读取文章数据

serializedPost=GET post:42:data

#将文章数据反序列化成文章的各个元素

title,content,author,time=unserialize(serializedPost)

#获取并递增文章的访问数量

count=INCR post:42:page.view

除了使用序列化函数将文章的多个元素存入一个字符串类型键中外，还可以对每个元素使用一个字符串类型键来存储，这种方法会在3.3.3节讨论。

3.2.4 命令拾遗

1．增加指定的整数

INCRBY key increment

INCRBY命令与INCR命令基本一样，只不过前者可以通过increment参数指定一次增加的数值，如：

redis＞INCRBY bar 2

(integer) 2

redis＞INCRBY bar 3

(integer) 5

2．减少指定的整数

DECR key

DECRBY key decrement

DECR命令与INCR命令用法相同，只不过是让键值递减，例如：

redis＞DECR bar

(integer)4

而DECRBY命令的作用不用介绍想必读者就可以猜到，DECRBY key 5 相当于INCRBY key -5。

3．增加指定浮点数

INCRBYFLOAT key increment

INCRBYFLOAT 命令类似INCRBY命令，差别是前者可以递增一个双精度浮点数，如：

redis＞INCRBYFLOAT bar 2.7

"6.7"

redis＞INCRBYFLOAT bar 5E+4

"50006.69999999999999929"

4．向尾部追加值

APPEND key value

APPEND作用是向键值的末尾追加value。如果键不存在则将该键的值设置为value，即相当于SET key value。返回值是追加后字符串的总长度。例如：

redis＞SET key hello

OK

redis＞APPEND key " world!"

(integer) 12

此时key的值是"hello world!"。APPEND命令的第二个参数加了双引号，原因是该参数包含空格，在redis-cli中输入需要双引号以示区分。

5．获取字符串长度

STRLEN key

STRLEN命令返回键值的长度，如果键不存在则返回0。例如：

redis＞STRLEN key

(integer)12

redis＞SET key 你好

OK

redis＞STRLEN key

(integer)6

前面提到了字符串类型可以存储二进制数据，所以它可以存储任何编码的字符串。例子中Redis接收到的是使用UTF-8编码的中文，由于“你”和“好”两个字的UTF-8编码的长度都是3，所以此例中会返回6。

6．同时获得/设置多个键值

MGET key [key …]

MSET key value [key value …]

MGET/MSET与GET/SET相似，不过MGET/MSET可以同时获得/设置多个键的键值。例如：

redis＞MSET key1 v1 key2 v2 key3 v3

OK

redis＞GET key2

"v2"

redis＞MGET key1 key3

1) "v1"

2) "v3"

7．位操作

GETBIT key offset

SETBIT key offset value

BITCOUNT key [start] [end]

BITOP operation destkey key [key …]

一个字节由8个二进制位组成，Redis提供了4个命令可以直接对二进制位进行操作。为了演示，我们首先将foo键赋值为bar：

redis＞SET foo bar

OK

bar的3个字母对应的ASCII码分别为98、97和114，转换成二进制后分别为1100010、1100001和1110010，所以foo键中的二进制位结构如图3-3所示。

图3-3 bar 的二进制存储结构

GETBIT命令可以获得一个字符串类型键指定位置的二进制位的值（0或1），索引从0开始：

redis＞GETBIT foo 0

(integer) 0

redis＞GETBIT foo 6

(integer) 1

如果需要获取的二进制位的索引超出了键值的二进制位的实际长度则默认位值是0：

redis＞GETBIT foo 100000

(integer) 0

SETBIT 命令可以设置字符串类型键指定位置的二进制位的值，返回值是该位置的旧值。如我们要将foo键值设置为aar，可以通过位操作将foo键的二进制位的索引第6位设为0，第7位设为1：

redis＞SETBIT foo 6 0

(integer) 1

redis＞SETBIT foo 7 1

(integer) 0

redis＞GET foo

"aar"

如果要设置的位置超过了键值的二进制位的长度，SETBIT命令会自动将中间的二进制位设置为0，同理设置一个不存在的键的指定二进制位的值会自动将其前面的位赋值为0：

redis＞SETBIT nofoo 10 1

(integer) 0

redis＞GETBIT nofoo 5

(integer) 0

BITCOUNT命令可以获得字符串类型键中值是1的二进制位个数，例如：

redis＞BITCOUNT foo

(integer)10

可以通过参数来限制统计的字节范围，如我们只希望统计前两个字节（即"aa"）：

redis＞BITCOUNT foo 0 1

(integer)6

BITOP命令可以对多个字符串类型键进行位运算，并将结果存储在destkey参数指定的键中。BITOP命令支持的运算操作有AND、OR、XOR 和NOT。如我们可以对bar和aar进行OR运算：

redis＞SET foo1 bar

OK

redis＞SET foo2 aar

OK

redis＞BITOP OR res foo1 foo2

(integer) 3

redis＞GET res

"car"

运算过程如图3-4所示。

图3-4 OR运算过程示意

利用位操作命令可以非常紧凑地存储布尔值。比如某网站的每个用户都有一个递增的整数ID，如果使用一个字符串类型键配合位操作来记录每个用户的性别（用户ID作为索引，二进制位值1和0表示男性和女性），那么记录100万个用户的性别只需占用100 KB多的空间，而且由于GETBIT和SETBIT的时间复杂度都是0(1)，所以读取二进制位值性能很高。

3.3 散列类型

小白只用了半个多小时就把访问统计和发表文章两个部分做好了。同时借助Bootstrap框架① ,老师花了一小会儿时间教会了之前只涉猎过HTML的小白如何做出一个像样的网页界面。

注释：①见http://twitter.github.com/bootstrap。

接着小白发问：

接下来我想要做的功能是博客的文章列表页，我设想在列表页中每个文章只显示标题部分，可是使用您刚才介绍的方法，若想取得文章的标题，必须把整个文章数据字符串取出来反序列化，而其中占用空间最大的文章內容部分却是不需要的，这样难道不会在传输和处理时造成资源浪费吗？

老师有些惊喜地看着小白答道：“很对！”同时以一个夸张的幅度点了下头，接着说：

这正是我接下来准备讲的。不仅取数据时会有资源浪费，在修改数据时也会有这个问题，比如当你只想更改文章的标题时也不得不把整个文章数据字符串更新一遍。

没等小白再问，老师就又继续说道：

前面我说过Redis的强大特性之一就是提供了多种实用的数据类型，其中的散列类型可以非常好地解决这个问题。

3.3.1 介绍

我们现在已经知道Redis是采用字典结构以键值对的形式存储数据的，而散列类型（hash）的键值也是一种字典结构，其存储了字段（field）和字段值的映射，但字段值只能是字符串，不支持其他数据类型，换句话说，散列类型不能嵌套其他的数据类型。一个散列类型键可以包含至多232-1个字段。

提示  除了散列类型，Redis的其他数据类型同样不支持数据类型嵌套。比如集合类型的每个元素都只能是字符串，不能是另一个集合或散列表等。

散列类型适合存储对象：使用对象类别和ID构成键名，使用字段表示对象的属性，而字段值则存储属性值。例如要存储ID为2的汽车对象，可以分别使用名为color、name和price的3个字段来存储该辆汽车的颜色、名称和价格。存储结构如图3-5所示。

图3-5 使用散列类型存储汽车对象的结构图

回想在关系数据库中如果要存储汽车对象，存储结构如表3-2所示。

表3-2  关系数据库存储汽车资料的表结构

数据是以二维表的形式存储的，这就要求所有的记录都拥有同样的属性，无法单独为某条记录增减属性。如果想为ID为1的汽车增加生产日期属性，就需要把数据表更改为如表3-3所示的结构。

表3-3  为其中一辆汽车增加一个“属性”

对于ID为2和3的两条记录而言date字段是冗余的。可想而知当不同的记录需要不同的属性时，表的字段数量会越来越多以至于难以维护。而且当使用ORM① 将关系数据库中的对象实体映射成程序中的实体时，修改表的结构往往意味着要中断服务（重启网站程序）。为了防止这些问题，在关系数据库中存储这种半结构化数据还需要额外的表才行。

注释：①即Object-Relational Mapping（对象关系映射）。

而Redis的散列类型则不存在这个问题。虽然我们在图3-5中描述了汽车对象的存储结构，但是这个结构只是人为的约定，Redis并不要求每个键都依据此结构存储，我们完全可以自由地为任何键增减字段而不影响其他键。

3.3.2 命令

1．赋值与取值

HSET key field value

HGET key field

HMSET key field value [field value …]

HMGET key field [field …]

HGETALL key

HSET命令用来给字段赋值，而HGET命令用来获得字段的值。用法如下：

redis＞HSET car price 500

(integer) 1

redis＞HSET car name BMW

(integer) 1

redis＞HGET car name

"BMW"

HSET命令的方便之处在于不区分插入和更新操作，这意味着修改数据时不用事先判断字段是否存在来决定要执行的是插入操作（update）还是更新操作（insert）。当执行的是插入操作时（即之前字段不存在）HSET命令会返回1，当执行的是更新操作时（即之前字段已经存在）HSET命令会返回0。更进一步，当键本身不存在时，HSET命令还会自动建立它。

提示  在Redis中每个键都属于一个明确的数据类型，如通过HSET命令建立的键是散列类型，通过SET命令建立的键是字符串类型等。使用一种数据类型的命令操作另一种数据类型的键会提示错误：“ERR Operation against a key holding the wrong kind of value”① 。

注释：①并不是所有命令都是如此，比如SET命令可以覆盖已经存在的键而不论原来键是什么类型。

当需要同时设置多个字段的值时，可以使用HMSET命令。例如，下面两条语句

HSET key field1 value1

HSET key field2 value2

可以用HMSET命令改写成

HMSET key field1 value1 field2 value2

相应地，HMGET命令可以同时获得多个字段的值：

redis＞HMGET car price name

1) "500"

2) "BMW"

如果想获取键中所有字段和字段值却不知道键中有哪些字段时（如3.3.1节介绍的存储汽车对象的例子，每个对象拥有的属性都未必相同）应该使用HGETALL命令。如：

redis＞HGETALL car

1) "price"

2) "500"

3) "name"

4) "BMW"

返回的结果是字段和字段值组成的列表，不是很直观，好在很多语言的Redis客户端会将HGETALL的返回结果封装成编程语言中的对象，处理起来就非常方便了。例如，在Node.js中：

redis.hgetall("car", function (error,car){

//hgetall方法的返回的值被封装成了JavaScript的对象

console.log(car.price);

console.log(car.name);

});

2．判断字段是否存在

HEXISTS key field

HEXISTS命令用来判断一个字段是否存在。如果存在则返回1，否则返回0（如果键不存在也会返回0）。

redis＞HEXISTS car model

(integer) 0

redis＞HSET car model C200

(integer) 1

redis＞HEXISTS car model

(integer) 1

3．当字段不存在时赋值

HSETNX key field value

HSETNX① 命令与HSET命令类似，区别在于如果字段已经存在，HSETNX命令将不执行任何操作。其实现可以表示为如下伪代码：

注释：①HSETNX 中的“NX”表示“if Not eXists”（如果不存在）。

def hsetnx(key, field, value)

isExists=HEXISTS key, field

if isExists is 0

HSET key, field, value

return 1

else

return 0

只不过HSETNX命令是原子操作，不用担心竞态条件。

4．增加数字

HINCRBY key field increment

上一节的命令拾遗部分介绍了字符串类型的命令INCRBY，HINCRBY命令与之类似，可以使字段值增加指定的整数。散列类型没有HINCR命令，但是可以通过HINCRBY keyfield 1来实现。

HINCRBY命令的示例如下：

redis＞HINCRBY person score 60

(integer) 60

之前person键不存在，HINCRBY命令会自动建立该键并默认score字段在执行命令前的值为“0”。命令的返回值是增值后的字段值。

5．删除字段

HDEL key field [field …]

HDEL命令可以删除一个或多个字段，返回值是被删除的字段个数：

redis＞HDEL car price

(integer) 1

redis＞HDEL car price

(integer) 0

3.3.3 实践

1．存储文章数据

3.2.3节介绍了可以将文章对象序列化后使用一个字符串类型键存储，可是这种方法无法提供对单个字段的原子读写操作支持，从而产生竞态条件，如两个客户端同时获得并反序列化某个文章的数据，然后分别修改不同的属性后存入，显然后存入的数据会覆盖之前的数据，最后只会有一个属性被修改。另外如小白所说，即使只需要文章标题，程序也不得不将包括文章内容在内的所有文章数据取出并反序列化，比较消耗资源。

除此之外，还有一种方法是组合使用多个字符串类型键来存储一篇文章的数据，如图3-6所示。

图3-6 使用多个字符串类型键存储一个对象

使用这种方法的好处在于无论获取还是修改文章数据，都可以只对某一属性进行操作，十分方便。而本章介绍的散列类型则更适合此场景，使用散列类型的存储结构如图3-7所示。

图3-7 使用一个散列类型键存储一个对象

从图3-7可以看出使用散列类型存储文章数据比图3-6所示的方法看起来更加直观也更容易维护（比如可以使用HGETALL命令获得一个对象的所有字段，删除一个对象时只需要删除一个键），另外存储同样的数据散列类型往往比字符串类型更加节约空间，具体的细节会在4.6节中介绍。

2．存储文章缩略名

使用过WordPress的读者可能会知道发布文章时一般需要指定一个缩略名（slug）来构成该篇文章的网址的一部分，缩略名必须符合网址规范且最好可以与文章标题含义相似，如“This Is A Great Post!”的缩略名可以为“this-is-a-great-post”。每个文章的缩略名必须是唯一的，所以在发布文章时程序需要验证用户输入的缩略名是否存在，同时也需要通过缩略名获得文章的 ID。

我们可以使用一个散列类型的键slug.to.id来存储文章缩略名和ID之间的映射关系。其中字段用来记录缩略名，字段值用来记录缩略名对应的ID。这样就可以使用HEXISTS命令来判断缩略名是否存在，使用HGET命令来获得缩略名对应的文章ID了。

现在发布文章可以修改成如下代码：

postID=INCR posts:count

#判断用户输入的slug是否可用，如果可用则记录

isSlugAvailable=HSETNX slug.to.id, slug, postID

if isSlugAvailable is 0

#slug已经用过了，需要提示用户更换slug,

#这里为了演示方便直接退出。

exit

HMSET post:postID, title, title, content, content, slug, slug,...

这段代码使用了HSETNX命令原子地实现了HEXISTS和HSET两个命令以避免竞态条件。当用户访问文章时，我们从网址中得到文章的缩略名，并查询slug.to.id键来获取文章ID：

postID=HGET slug.to.id, slug

if not postID

print文章不存在

exit

post=HGETALL post:postID

print文章标题：post.title

需要注意的是如果要修改文章的缩略名一定不能忘了修改slug.to.id键对应的字段。如要修改ID为42的文章的缩略名为newSlug 变量的值：

#判断新的slug是否可用，如果可用则记录

isSlugAvailable=HSETNX slug.to.id, newSlug, 42

if isSlugAvailable is 0

exit

#获得旧的缩略名

oldSlug=HGET post:42, slug

#设置新的缩略名

HSET post:42, slug, newSlug

#删除旧的缩略名

HDEL slug.to.id, oldSlug

3.3.4 命令拾遗

1．只获取字段名或字段值

HKEYS key

HVALS key

有时仅仅需要获取键中所有字段的名字而不需要字段值，那么可以使用HKEYS命令，就像这样：

redis＞HKEYS car

1) "name"

2) "model"

HVALS命令与HKEYS命令相对应，HVALS命令用来获得键中所有字段值，例如：

redis＞HVALS car

1) "BMW"

2) "C200"

2．获得字段数量

HLEN key

例如：

redis＞HLEN car

(integer) 2

3.4 列表类型

正当小白踌躇满志地写着文章列表页的代码时，一个很重要的问题阻碍了他的开发，于是他请来了宋老师为他讲解。

原来小白是使用如下流程获得文章列表的：

（1）读取posts:count键获得博客中最大的文章ID；

（2）根据这个ID来计算当前列表页面中需要展示的文章ID列表（小白规定博客每页只显示10篇文章，按照ID的倒序排列），如第n页的文章ID范围是从“最大的文章ID-(n-1) ＊ 10”到“max(最大的文章ID-n ＊ 10+1,1)”；

（3）对每个ID使用HMGET命令来获得文章数据。

对应的伪代码如下：

#每页显示10篇文章

postsPerPage=10

#获得最后发表的文章ID

lastPostID=GET posts:count

#currentPage存储的是当前页码，第一页时$currentPage的值为1，依此类推

start=lastPostID-(currentPage-1) ＊ postsPerPage

end=max(lastPostID-currentPage ＊ postsPerPage+1,1)

#遍历文章ID获取数据

for i=start down to end

#获取文章的标题和作者并打印出来

post=HMGET post:i, title, author

print post[0] #文章标题

print post[1] #文章作者

可是这种方式要求用户不能删除文章以保证ID连续，否则小白就必须在程序中使用EXISTS 命令判断某个ID的文章是否存在，如果不存在则跳过。由于每删除一篇文章都会影响后面的页码分布，为了保证每页的文章列表都能正好显示10篇文章，不论是第几页，都不得不从最大的文章ID开始遍历来获得当前页面应该显示哪些文章。

小白摇了摇头，心想：“真是个灾难！”然后看向宋老师，试探地问道：“我想到了KEYS命令，可不可以使用KEYS命令获得所有以“post:”开头的键，然后再根据键名分页呢？”

宋老师回答道：“确实可行，不过KEYS命令需要遍历数据库中的所有键，出于性能考虑一般很少在生产环境中使用这个命令。至于你提到的问题，可以使用Redis的列表类型来解决。”

3.4.1 介绍

列表类型（list）可以存储一个有序的字符串列表，常用的操作是向列表两端添加元素，或者获得列表的某一个片段。

列表类型内部是使用双向链表（double linked list）实现的，所以向列表两端添加元素的时间复杂度为0(1)，获取越接近两端的元素速度就越快。这意味着即使是一个有几千万个元素的列表，获取头部或尾部的10条记录也是极快的（和从只有20个元素的列表中获取头部或尾部的10条记录的速度是一样的）。

不过使用链表的代价是通过索引访问元素比较慢，设想在iPad mini发售当天有1000个人在三里屯的苹果店排队等候购买，这时苹果公司宣布为了感谢大家的排队支持，决定奖励排在第486位的顾客一部免费的iPad mini。为了找到这第486位顾客，工作人员不得不从队首一个一个地数到第486个人。但同时，无论队伍多长，新来的人想加入队伍的话直接排到队尾就好了，和队伍里有多少人没有任何关系。这种情景与列表类型的特性很相似。

这种特性使列表类型能非常快速地完成关系数据库难以应付的场景：如社交网站的新鲜事，我们关心的只是最新的内容，使用列表类型存储，即使新鲜事的总数达到几千万个，获取其中最新的100条数据也是极快的。同样因为在两端插入记录的时间复杂度是0(1)，列表类型也适合用来记录日志，可以保证加入新日志的速度不会受到已有日志数量的影响。

借助列表类型，Redis还可以作为队列使用，4.4节会详细介绍。

与散列类型键最多能容纳的字段数量相同，一个列表类型键最多能容纳232-1个元素。

3.4.2 命令

1．向列表两端增加元素

LPUSH key value [value …]

RPUSH key value [value …]

LPUSH命令用来向列表左边增加元素，返回值表示增加元素后列表的长度。

redis＞LPUSH numbers 1

(integer) 1

这时numbers键中的数据如图3-8所示。LPUSH命令还支持同时增加多个元素，例如：

图3-8 加入元素1后numbers键中的数据

redis＞LPUSH numbers 2 3

(integer) 3

LPUSH会先向列表左边加入"2"，然后再加入"3"，所以此时numbers键中的数据如图3-9所示。

图3-9 加入元素2和3后numbers键中的数据

向列表右边增加元素的话则使用RPUSH命令，其用法和LPUSH命令一样：

redis＞RPUSH numbers 0-1

(integer) 5

此时numbers 键中的数据如图3-10所示。

图3-10 使用RPUSH 命令加入元素0，−1 后numbers 键中的数据

2．从列表两端弹出元素

LPOP key

RPOP key

有进有出，LPOP命令可以从列表左边弹出一个元素。LPOP命令执行两步操作：第一步是将列表左边的元素从列表中移除，第二步是返回被移除的元素值。例如，从numbers列表左边弹出一个元素（也就是"3"）：

redis＞LPOP numbers

"3"

此时numbers键中的数据如图3-11所示。

图3-11 从左侧弹出元素后numbers 键中的数据

同样，RPOP命令可以从列表右边弹出一个元素：

redis＞RPOP numbers

"-1"

此时numbers键中的数据如图3-12所示。

图3-12 从右侧弹出元素后numbers 键中的数据

结合上面提到的4个命令可以使用列表类型来模拟栈和队列的操作：如果想把列表当做栈，则搭配使用LPUSH和LPOP或RPUSH和RPOP，如果想当成队列，则搭配使用LPUSH和RPOP或RPUSH和LPOP。

3．获取列表中元素的个数

LLEN key

当键不存在时LLEN会返回0：

redis＞LLEN numbers

(integer) 3

LLEN命令的功能类似SQL语句SELECT COUNT(＊) FROM table_name，但是LLEN的时间复杂度为0(1)，使用时Redis会直接读取现成的值，而不需要像部分关系数据库（如使用InnoDB存储引擎的MySQL 表）那样需要遍历一遍数据表来统计条目数量。

4．获得列表片段

LRANGE key start stop

LRANGE命令是列表类型最常用的命令之一，它能够获得列表中的某一片段。LRANGE命令将返回索引从start到stop之间的所有元素（包含两端的元素）。与大多数人的直觉相同，Redis的列表起始索引为0：

redis＞LRANGE numbers 0 2

1) "2"

2) "1"

3) "0"

LRANGE命令在取得列表片段的同时不会像LPOP一样删除该片段，另外LRANGE命令与很多语言中用来截取数组片段的方法slice有一点区别是LRANGE返回的值包含最右边的元素，如在JavaScript中：

var numbers=[2, 1, 0];

console.log(numbers.slice(0, 2)); //返回数组：[2, 1]

LRANGE命令也支持负索引，表示从右边开始计算序数，如"-1"表示最右边第一个元素，"-2"表示最右边第二个元素，依次类推：

redis＞LRANGE numbers -2 -1

1) "1"

2) "0"

显然，LRANGE numbers 0 -1可以获取列表中的所有元素。另外一些特殊情况如下。

（1）如果start的索引位置比stop的索引位置靠后，则会返回空列表。

（2）如果stop大于实际的索引范围，则会返回到列表最右边的元素：

redis＞LRANGE numbers 1 999

1) "1"

2) "0"

5．删除列表中指定的值

LREM key count value

LREM命令会删除列表中前count个值为value的元素，返回值是实际删除的元素个数。根据count值的不同，LREM命令的执行方式会略有差异：

●当count＞0时LREM命令会从列表左边开始删除前count个值为value的元素；

●当count＜0时LREM 命令会从列表右边开始删除前|count|个值为value的元素；

●当count=0是LREM命令会删除所有值为value的元素。例如：

redis＞RPUSH numbers 2

(integer) 4

redis＞LRANGE numbers 0 -1

1) "2"

2) "1"

3) "0"

4) "2"

#从右边开始删除第一个值为"2"的元素

redis＞LREM numbers -1 2

(integer) 1

redis＞LRANGE numbers 0 -1

1) "2"

2) "1"

3) "0"

3.4.3 实践

1．存储文章ID列表

为了解决小白遇到的问题，我们使用列表类型键posts:list记录文章ID列表。当发布新文章时使用LPUSH命令把新文章的ID加入这个列表中，另外删除文章时也要记得把列表中的文章ID删除，就像这样：LREM posts:list 1要删除的文章ID

有了文章ID列表，就可以使用LRANGE命令来实现文章的分页显示了。伪代码如下：

postsPerPage=10

start=(currentPage - 1) ＊ postsPerPage

end=currentPage ＊ postsPerPage-1

postsID=LRANGE posts:list, start, end

#获得了此页需要显示的文章ID列表，我们通过循环的方式来读取文章

for each id in postsID

post=HGETALL post:id

print文章标题：post.title

这样显示的文章列表是根据加入列表的顺序倒序的（即最新发布的文章显示在前面），如果想让最旧的文章显示在前面，可以使用LRANGE命令获取需要的部分并在客户端中将顺序反转显示出来，具体的实现交由读者来完成。

小白的问题至此就解决了，美中不足的一点是散列类型没有类似字符串类型的MGET命令那样可以通过一条命令同时获得多个键的键值的版本，所以对于每个文章ID都需要请求一次数据库，也就都会产生一次往返时延（round-trip delay time）① ，之后我们会介绍使用管道和脚本来优化这个问题。

注释：①4.5节还会详细介绍这个概念。

另外使用列表类型键存储文章ID列表有以下两个问题。

（1）文章的发布时间不易修改：修改文章的发布时间不仅要修改post:文章ID中的time字段，还需要按照实际的发布时间重新排列posts:list中的元素顺序，而这一操作相对比较繁琐。

（2）当文章数量较多时访问中间的页面性能较差：前面已经介绍过，列表类型是通过链表实现的，所以当列表元素非常多时访问中间的元素效率并不高。

但如果博客不提供修改文章时间的功能并且文章数量也不多时，使用列表类型也不失为一种好办法。对于小白要做的博客系统来讲，现阶段的成果已经足够实用且值得庆祝了。3.6节将介绍使用有序集合类型存储文章ID列表的方法。

2．存储评论列表

在博客中还可以使用列表类型键存储文章的评论。由于小白的博客不允许访客修改自己发表的评论，而且考虑到读取评论时需要获得评论的全部数据（评论者姓名，联系方式，评论时间和评论内容），不像文章一样有时只需要文章标题而不需要文章正文。所以适合将一条评论的各个元素序列化成字符串后作为列表类型键中的元素来存储。

我们使用列表类型键post:文章ID:comments来存储某个文章的所有评论。发布评论的伪代码如下（以ID为42的文章为例）：

#将评论序列化成字符串

serializedComment=serialize(author, email, time, content)

LPUSH post:42:comments, serializedComment

读取评论时同样使用LRANGE命令即可，具体的实现在此不再赘述。

3.4.5 命令拾遗

1．获得/设置指定索引的元素值

LINDEX key index

LSET key index value

如果要将列表类型当作数组来用，LINDEX命令是必不可少的。LINDEX命令用来返回指定索引的元素，索引从0开始。如：

redis＞LINDEX numbers 0"

2"

如果index是负数则表示从右边开始计算的索引，最右边元素的索引是-1。例如：

redis＞LINDEX numbers -1

"0"

LSET是另一个通过索引操作列表的命令，它会将索引为index的元素赋值为value。例如：

redis＞LSET numbers 1 7

OK

redis＞LINDEX numbers 1

"7"

2．只保留列表指定片段

LTRIM key start end

LTRIM命令可以删除指定索引范围之外的所有元素，其指定列表范围的方法和LRANGE命令相同。就像这样：

redis＞LRANGE numbers 0 1

1) "1"

2) "2"

3) "7"

4) "3"

"0"

redis＞LTRIM numbers 1 2

OK

redis＞LRANGE numbers 0 1

1) "2"

2) "7"

LTRIM命令常和LPUSH命令一起使用来限制列表中元素的数量，比如记录日志时我们希望只保留最近的100条日志，则每次加入新元素时调用一次LTRIM命令即可：

LPUSH logs newLog

LTRIM logs 0 99

3．向列表中插入元素

LINSERT key BEFORE|AFTER pivot value

LINSERT命令首先会在列表中从左到右查找值为pivot的元素，然后根据第二个参数是BEFORE还是AFTER来决定将value插入到该元素的前面还是后面。

LINSERT命令的返回值是插入后列表的元素个数。示例如下：

redis＞LRANGE numbers 0 -1

1) "2"

2) "7"

3) "0"

redis＞LINSERT numbers AFTER 7 3

(integer) 4

redis＞LRANGE numbers 0 -1

1) "2"

2) "7"

3) "3"

4) "0"

redis＞LINSERT numbers BEFORE 2 1

(integer) 5

redis＞LRANGE numbers 0 -1

1) "1"

2) "2"

3) "7"

4) "3"

5) "0"

4．将元素从一个列表转到另一个列表R

POPLPUSH source destination

RPOPLPUSH是个很有意思的命令，从名字就可以看出它的功能：先执行RPOP命令再执行LPUSH 命令。RPOPLPUSH命令会先从source列表类型键的右边弹出一个元素，然后将其加入到destination列表类型键的左边，并返回这个元素的值，整个过程是原子的。其具体实现可以表示为伪代码：

def rpoplpush(source, destination)

value=RPOP source

LPUSH destination, value

return value

当把列表类型作为队列使用时，RPOPLPUSH命令可以很直观地在多个队列中传递数据。当source和destination相同时，RPOPLPUSH命令会不断地将队尾的元素移到队首，借助这个特性我们可以实现一个网站监控系统：使用一个队列存储需要监控的网址，然后监控程序不断地使用RPOPLPUSH命令循环取出一个网址来测试可用性。这里使用RPOPLPUSH命令的好处在于在程序执行过程中仍然可以不断地向网址列表中加入新网址，而且整个系统容易扩展，允许多个客户端同时处理队列。

3.5 集合类型

博客首页，文章页面，评论页面……眼看着博客逐渐成型，小白的心情也是越来越好。时间已经到了深夜，小白却还陶醉于编码之中。不过一个他无法解决的问题最终还是让他不得不提早睡觉去：小白不知道该怎么在Redis中存储文章标签（tag）。他想过使用散列类型或列表类型存储，虽然都能实现，但是总觉得颇有不妥，再加上之前几天领略了Redis的强大功能后小白相信一定有一种合适的数据类型能满足他的需求。于是小白给宋老师发了封询问邮件后就睡觉去了。

转天一早就收到了宋老师的回复：

你很善于思考嘛！你想的没错，Redis有一种数据类型很适合存储文章的标签，它就是集合类型。

3.5.1 介绍

集合的概念高中的数学课就学习过。在集合中的每个元素都是不同的，且没有顺序。一个集合类型（set）键可以存储至多232-1个（相信这个数字对大家来说已经很熟悉了）字符串。

集合类型和列表类型有相似之处，但很容易将它们区分开来，如表3-4所示。

表3-4  集合类型和列表类型对比

集合类型的常用操作是向集合中加入或删除元素、判断某个元素是否存在等，由于集合类型在Redis内部是使用值为空的散列表（hash table）实现的，所以这些操作的时间复杂度都是0(1)。最方便的是多个集合类型键之间还可以进行并集、交集和差集运算，稍后就会看到灵活运用这一特性带来的便利。

3.5.2 命令

1．增加/删除元素

SADD key member [member …]

SREM key member [member …]

SADD命令用来向集合中增加一个或多个元素，如果键不存在则会自动创建。因为在一个集合中不能有相同的元素，所以如果要加入的元素已经存在于集合中就会忽略这个元素。本命令的返回值是成功加入的元素数量（忽略的元素不计算在内）。例如：

redis＞SADD letters a

(integer) 1

redis＞ SADD letters a b c

(integer) 2

第二条SADD命令的返回值为2是因为元素“a”已经存在，所以实际上只加入了两个元素。

SREM命令用来从集合中删除一个或多个元素，并返回删除成功的个数，例如：

redis＞SREM letters c d

(integer) 1

由于元素“d”在集合中不存在，所以只删除了一个元素，返回值为1。

2．获得集合中的所有元素

SMEMBERS key

SMEMBERS命令会返回集合中的所有元素，例如：

redis＞SMEMBERS letters

1) "b"

2) "a"

3．判断元素是否在集合中

SISMEMBER key member

判断一个元素是否在集合中是一个时间复杂度为0(1)的操作，无论集合中有多少个元素，SISMEMBER命令始终可以极快地返回结果。当值存在时SISMEMBER命令返回1，当值不存在或键不存在时返回0，例如：

redis＞SISMEMBER letters a

(integer) 1

redis＞SISMEMBER letters d

(integer) 0

4．集合间运算

SDIFF key [key …]

SINTER key [key …]

SUNION key [key …]

接下来要介绍的3个命令都是用来进行多个集合间运算的。

（1）SDIFF命令用来对多个集合执行差集运算。集合A与集合B的差集表示为A-B，代表所有属于A且不属于B的元素构成的集合（如图3-13所示），即A-B={x|x∈A且x∈/B}。例如：

图3-13 斜线部分表示的是A−B

{1, 2, 3}-{2, 3, 4}={1}

{2, 3, 4}-{1, 2, 3}={4}

SDIFF命令的使用方法如下：

redis＞SADD setA 1 2 3

(integer) 3

redis＞SADD setB 2 3 4

(integer) 3

redis＞SDIFF setA setB

1) "1"

redis＞SDIFF setB setA

1 ) "4"

SDIFF 命令支持同时传入多个键，例如：

redis＞SADD setC 2 3

(integer) 2

redis＞SDIFF setA setB setC

1 ) "1"

计算顺序是先计算setA-setB，再计算结果与setC的差集。

（2）SINTER命令用来对多个集合执行交集运算。集合A与集合B的交集表示为A∩B，代表所有属于A且属于B的元素构成的集合（如图3-14所示），即A∩B={x|x∈A且x∈B}。例如：

图3-14 图中斜线部分表示A∩B

{1, 2, 3}∩{2, 3, 4}={2, 3}

SINTER命令的使用方法如下：

redis＞SINTER setA setB

1) "2"

2) "3"

SINTER命令同样支持同时传入多个键，如：

redis＞SINTER setA setB setC

1) "2"

2) "3"

（3）SUNION命令用来对多个集合执行并集运算。集合A与集合B的并集表示为AUB，代表所有属于A或属于B的元素构成的集合（如图3-15所示），即AUB={x|x∈A 或x∈B}。例如：

图3-15 图中斜线部分表示A∪B

{1, 2, 3}∪{2, 3, 4}={1, 2, 3, 4}

SUNION命令的使用方法如下：

redis＞SUNION setA setB

1) "1"

2) "2"

3) "3"

4) "4"

SUNION命令同样支持同时传入多个键，例如：

redis＞SUNION setA setB setC

1) "1"

2) "2"

3) "3"

4) "4"

3.5.3 实践

1．存储文章标签

考虑到一个文章的所有标签都是互不相同的，而且展示时对这些标签的排列顺序并没有要求，我们可以使用集合类型键存储文章标签。

对每篇文章使用键名为“post:文章 ID:tags”的键存储该篇文章的标签。具体操作如伪代码：

#给ID为42的文章增加标签：

SADD post:42:tags, 闲言碎语, 技术文章, Java

# 删除标签：

SREM post:42:tags, 闲言碎语

#显示所有的标签:

tags=SMEMBERS post:42:tags

print tags

使用集合类型键存储标签适合需要单独增加或删除标签的场合。如在WordPress博客程序中无论是添加还是删除标签都是针对单个标签的（图3-16），可以直观地使用SADD和SREM命令完成操作。

图3-16 在WordPress中设置文章标签

另一方面，有些地方需要用户直接设置所有标签后一起上传修改，图3-17所示是某网站的个人资料编辑页面，用户编辑自己的爱好后提交，程序直接覆盖原来的标签数据，整个过程没有针对单个标签的操作，并未利用到集合类型的优势，所以此时也可以直接使用字符串类型键存储标签数据。

图3-17 在百度中设置个人爱好

之所以特意提到这个在实践中的差别是想说明对于Redis存储方式的选择并没有绝对的规则，比如3.4节介绍过使用列表类型存储访客评论，但是在一些特定的场合下散列类型甚至字符串类型图3-17 在百度中设置个人爱好可能更适合。

2．通过标签搜索文章

有时我们还需要列出某个标签下的所有文章，甚至需要获得同时属于某几个标签的文章列表，这种需求在传统关系数据库中实现起来比较复杂，下面举一个例子。

表3-5  posts表结构

表3-6  tags表结构

表3-7  posts_tags表结构

为了找到同时属于“Java”、“M ySQL”和“Redis”这3个标签的文章，需要使用如下的SQL语句：

SELECT p.post_title

FROM posts_tags pt,

posts p,

tags t

WHERE pt.tag_id = t.tag_id

AND (t.tag_name IN ('Java', 'MySQL', 'Redis'))

AND p.post_id = pt.post_id

GROUP BY p.post_id HAVING COUNT(p.post_id)=3;

可以很明显看到这样的SQL语句不仅效率相对较低，而且不易阅读和维护。而使用Redis可以很简单直接地实现这一需求。

具体做法是为每个标签使用一个名为“tag:标签名称:posts”的集合类型键存储标有该标签的文章ID列表。假设现在有3篇文章，ID分别为1、2、3，其中ID为1的文章标签是“Java”，ID为2的文章标签是“Java”、“MySQL”，ID为3的文章标签是“Java”、“MySQL”和“Redis”，则有关标签部分的存储结构如图3-18所示① 。

注释：①集合类型键中元素是无序的，图3-18中为了便于读者阅读将元素按照大小顺序进行了排列。

图3-18 和标签有关部分的存储结构

最简单的，当需要获取标记“MySQL”标签的文章时只需要使用命令SMEMBERStag:MySQL:posts即可。如果要实现找到同时属于Java、MySQL和Redis这3个标签的文章，只需要将tag:Java:posts、tag:MySQL:posts和tag:Redis:posts这3个键取交集，借助SINTER命令即可轻松完成。

3.5.4 命令拾遗

1．获得集合中元素个数

SCARD key

SCARD命令用来获得集合中的元素个数，例如：

redis＞SMEMBERS letters

1) "b"

2) "a"

redis＞SCARD letters

(integer) 2

2．进行集合运算并将结果存储

SDIFFSTORE destination key [key …]

SINTERSTORE destination key [key …]

SUNIONSTORE destination key [key …]

SDIFFSTORE命令和SDIFF命令功能一样，唯一的区别就是前者不会直接返回运算结果，而是将结果存储在destination键中。

SDIFFSTORE命令常用于需要进行多步集合运算的场景中，如需要先计算差集再将结果和其他键计算交集。

SINTERSTORE和SUNIONSTORE命令与之类似，不再赘述。

3．随机获得集合中的元素

SRANDMEMBER key [count]

SRANDMEMBER命令用来随机从集合中获取一个元素，如：

redis＞SRANDMEMBER letters

"a"

redis＞SRANDMEMBER letters

"b"

redis＞SRANDMEMBER letters

"a"

还可以传递count参数来一次随机获得多个元素，根据count的正负不同，具体表现也不同。

（1）当count为正数时，SRANDMEMBER会随机从集合里获得count个不重复的元素。如果count的值大于集合中的元素个数，则SRANDMEMBER会返回集合中的全部元素。

（2）当count为负数时，SRANDMEMBER会随机从集合里获得|count|个的元素，这些元素有可能相同。

为了示例，我们先在letters集合中加入两个元素：

redis＞SADD letters c d

(integer) 2

目前letters集合中共有“a”、“b”、“c”、“d”4个元素，下面使用不同的参数对SRANDMEMBER命令进行测试：

redis＞SRANDMEMBER letters 2

1) "a"

2) "c"

redis＞SRANDMEMBER letters 2

1) "a"

2) "b"

redis＞SRANDMEMBER letters 100

1) "b"

2) "a"

3) "c"

4) "d"

redis＞SRANDMEMBER letters -2

1) "b"

2) "b"

redis＞SRANDMEMBER letters -10

1) "b"

2) "b"

3) "c"

4) "c"

5) "b"

6) "a"

7) "b"

8) "d"

9) "b"

10) "b"

细心的读者可能会发现SRANDMEMBER命令返回的数据似乎并不是非常的随机，从SRANDMEMBER letters -10这个结果中可以很明显地看出这个问题（b元素出现的次数相对较多① ），出现这种情况是由集合类型采用的存储结构（散列表）造成的。散列表使用散列函数将元素映射到不同的存储位置（桶）上以实现0(1)时间复杂度的元素查找，举个例子，当使用散列表存储元素b时，使用散列函数计算出b的散列值是0，所以将b存入编号为0 的桶（bucket）中，下次要查找b时就可以用同样的散列函数再次计算b的散列值并直接到相应的桶中找到b。当两个不同的元素的散列值相同时会出现冲突，Redis使用拉链法来解决冲突，即将散列值冲突的元素以链表的形式存入同一桶中，查找元素时先找到元素对应的桶，然后再从桶中的链表中找到对应的元素。使用SRANDMEMBER命令从集合中获得一个随机元素时，Redis首先会从所有桶中随机选择一个桶，然后再从桶中的所有元素中随机选择一个元素，所以元素所在的桶中的元素数量越少，其被随机选中的可能性就越大，如图3-19所示。

注释：①如果你亲自跟着输入了命令可能会发现得到的结果与书中的结果并不相同，这是正常现象，见后文描述。

图3-19 Redis会先从3个桶中随机挑一个非空的桶，然后再从桶中随机选择一个元素，所以选中元素b的概率会大一些

4．从集合中弹出一个元素

SPOP key

3.4节中我们学习过LPOP命令，作用是从列表左边弹出一个元素（即返回元素的值并删除它）。SPOP命令的作用与之类似，但由于集合类型的元素是无序的，所以SPOP命令会从集合中随机选择一个元素弹出。例如：

redis＞SPOP letters

"b"

redis＞SMEMBERS letters

1) "a"

2) "c"

3) "d"

3.6 有序集合类型

了解了集合类型后，小白终于被Redis的强大功能所折服了，但他却不愿止步于此。这不，小白又想给博客加上按照文章访问量排序的功能：

老师您好，之前您已经介绍过了如何使用列表类型键存储文章ID列表，不过我还想加上按照文章访问量排序的功能，因为我觉得很多访客更希望看那些热门的文章。

宋老师回答到：

这个功能很好实现，不过要用到一个新的数据类型，也是我要介绍的最后一个数据类型——有序集合。

3.6.1 介绍

有序集合类型（sorted set）的特点从它的名字中就可以猜到，它与上一节介绍的集合类型的区别就是“有序”二字。

在集合类型的基础上有序集合类型为集合中的每个元素都关联了一个分数，这使得我们不仅可以完成插入、删除和判断元素是否存在等集合类型支持的操作，还能够获得分数最高（或最低）的前N个元素、获得指定分数范围内的元素等与分数有关的操作。虽然集合中每个元素都是不同的，但是它们的分数却可以相同。

有序集合类型在某些方面和列表类型有些相似。

（1）二者都是有序的。

（2）二者都可以获得某一范围的元素。

但是二者有着很大的区别，这使得它们的应用场景也是不同的。

（1）列表类型是通过链表实现的，获取靠近两端的数据速度极快，而当元素增多后，访问中间数据的速度会较慢，所以它更加适合实现如“新鲜事”或“日志”这样很少访问中间元素的应用。

（2）有序集合类型是使用散列表和跳跃表（Skip list）实现的，所以即使读取位于中间部分的数据速度也很快（时间复杂度是O(log(N))）。

（3）列表中不能简单地调整某个元素的位置，但是有序集合可以（通过更改这个元素的分数）。

（4）有序集合要比列表类型更耗费内存。

有序集合类型算得上是 Redis的5种数据类型中最高级的类型了，在学习时可以与列表类型和集合类型对照理解。

3.6.2 命令

1．增加元素

ZADD key score member [score member …]

ZADD命令用来向有序集合中加入一个元素和该元素的分数，如果该元素已经存在则会用新的分数替换原有的分数。ZADD命令的返回值是新加入到集合中的元素个数（不包含之前已经存在的元素）。

假设我们用有序集合模拟计分板，现在要记录Tom、Peter和David三名运动员的分数（分别是89分、67分和100分）：

redis＞ZADD scoreboard 89 Tom 67 Peter 100 David

(integer) 3

这时我们发现Peter的分数录入有误，实际的分数应该是76分，可以用ZADD命令修改Peter的分数：

redis＞ZADD scoreboard 76 Peter

(integer) 0

分数不仅可以是整数，还支持双精度浮点数：

redis＞ZADD testboard 17E+307 a

(integer) 1

redis＞ZADD testboard 1.5 b

(integer) 1

redis＞ZADD testboard +inf c

(integer) 1

redis＞ZADD testboard -inf d

(integer) 1

其中+inf和-inf分别表示正无穷和负无穷。

2．获得元素的分数

ZSCORE key member

示例如下：

redis＞ZSCORE scoreboard Tom

"89"

3．获得排名在某个范围的元素列表

ZRANGE key start stop [WITHSCORES]

ZREVRANGE key start stop [WITHSCORES]

ZRANGE命令会按照元素分数从小到大的顺序返回索引从start到stop之间的所有元素（包含两端的元素）。ZRANGE命令与LRANGE命令十分相似，如索引都是从0开始，负数代表从后向前查找（-1表示最后一个元素）。就像这样：

redis＞ZRANGE scoreboard 0 2

1) "Peter"

2) "Tom"

3) "David"

redis＞ZRANGE scoreboard 1 -1

1) "Tom"

2) "David"

如果需要同时获得元素的分数的话可以在ZRANGE命令的尾部加上WITHSCORES参数，这时返回的数据格式就从“元素1, 元素2, …, 元素n”变为了“元素1, 分数1, 元素2, 分数2, …, 元素n, 分数n”，例如：

redis＞ZRANGE scoreboard 0 -1 WITHSCORES

1) "Peter"

2) "76"

3) "Tom"

4) "89"

5) "David"

6) "100"

ZRANGE命令的时间复杂度为0(logn+m)（其中n为有序集合的基数，m为返回的元素个数）。

如果两个元素的分数相同，Redis会按照字典顺序（即"0"＜"9"＜"A"＜"Z"＜"a"＜"z"这样的顺序）来进行排列。再进一步，如果元素的值是中文怎么处理呢？答案是取决于中文的编码方式，如使用UTF-8编码：

redis＞ZADD chineseName 0 马华 0 刘墉 0 司马光 0 赵哲

(integer) 4

redis＞ZRANGE chineseName 0 -1

1) "\xe5\x88\x98\xe5\xa2\x89"

2) "\xe5\x8f\xb8\xe9\xa9\xac\xe5\x85\x89"

3) "\xe8\xb5\xb5\xe5\x93\xb2"

4) "\xe9\xa9\xac\xe5\x8d\x8e"

可见此时Redis依然按照字典顺序排列这些元素。

ZREVRANGE命令和ZRANGE的唯一不同在于ZREVRANGE命令是按照元素分数从大到小的顺序给出结果的。

4．获得指定分数范围的元素

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

ZRANGEBYSCORE命令参数虽然多，但是都很好理解。该命令按照元素分数从小到大的顺序返回分数在min和max之间（包含min和max）的元素：

redis＞ZRANGEBYSCORE scoreboard 80 100

1) "Tom"

2) "David"

如果希望分数范围不包含端点值，可以在分数前加上“(”符号。例如，希望返回80分到100分的数据，可以含80分，但不包含100分，则稍微修改一下上面的命令即可：

redis＞ZRANGEBYSCORE scoreboard 80 (100

1) "Tom"

min和max还支持无穷大，同ZADD命令一样，-inf 和+inf分别表示负无穷和正无穷。比如你希望得到所有分数高于80分（不包含80分）的人的名单，但你却不知道最高分是多少（虽然有些背离现实，但是为了叙述方便，这里假设可以获得的分数是无上限的），这时就可以用上+inf了：

redis＞ZRANGEBYSCORE scoreboard (80 +inf

1) "Tom"

2) "David"

WITHSCORES参数的用法与ZRANGE命令一样，不再赘述。

了解SQL语句的读者对LIMIT offset count应该很熟悉，在本命令中LIMIToffset count与SQL中的用法基本相同，即在获得的元素列表的基础上向后偏移offset个元素，并且只获取前count个元素。为了便于演示，我们先向scoreboard键中再增加些元素：

redis＞ZADD scoreboard 56 Jerry 92 Wendy 67 Yvonne

(integer) 3

现在scoreboard键中的所有元素为：

redis＞ZRANGE scoreboard 0 -1 WITHSCORES

1) "Jerry"

2) "56"

3) "Yvonne"

4) "67"

5) "Peter"

6) "76"

7) "Tom"

8) "89"

9) "Wendy"

10) "92"

11) "David"

12) "100"

想获得分数高于60分的从第二个人开始的3个人：

redis＞ZRANGEBYSCORE scoreboard 60 +inf LIMIT 1 3

1) "Peter"

2) "Tom"

3) "Wendy"

那么，如果想获取分数低于或等于100分的前3个人怎么办呢？这时可以借助ZREVRANGEBYSCORE命令实现。对照前文提到的ZRANGE命令和ZREVRANGE命令之间的关系，相信读者很容易能明白ZREVRANGEBYSCORE命令的功能。需要注意的是ZREVRANGEBYSCORE命令不仅是按照元素分数从大往小的顺序给出结果的，而且它的min和max参数的顺序和ZRANGEBYSCORE命令是相反的。就像这样：

redis＞ZREVRANGEBYSCORE scoreboard 100 0 LIMIT 0 3

1) "David"

2) "Wendy"

3) "Tom"

5．增加某个元素的分数

Z INCRBY key increment member

ZINCRBY命令可以增加一个元素的分数，返回值是更改后的分数。例如，想给Jerry加4分：

redis＞ZINCRBY scoreboard 4 Jerry

"60"

increment也可以是个负数表示减分，例如，给Jerry减4分：

redis＞ZINCRBY scoreboard -4 Jerry

" 56"

如果指定的元素不存在，Redis在执行命令前会先建立它并将它的分数赋为0再执行操作。

3.6.3 实践

1．实现按点击量排序

要按照文章的点击量排序，就必须再额外使用一个有序集合类型的键来实现。在这个键中以文章的ID作为元素，以该文章的点击量作为该元素的分数。将该键命名为posts:page.view，每次用户访问一篇文章时，博客程序就通过“ZINCRBY posts:page.view 1文章ID”更新访问量。

需要按照点击量的顺序显示文章列表时，有序集合的用法与列表的用法大同小异：

postsPerPage=10

start=(currentPage-1) ＊ postsPerPage

end=currentPage ＊ postsPerPage - 1

postsID=ZREVRANGE posts:page.view, start，end

for each id in postsID

postData=HGETALL post:id

print文章标题：postData.title

另外3.2节介绍过使用字符串类型键“post:文章ID:page.view”来记录单个文章的访问量，现在这个键已经不需要了，想要获得某篇文章的访问量可以通过ZSCOREposts:page.view文章ID来实现。

2．改进按时间排序

3.4 节介绍了每次发布新文章时都将文章的ID加入到名为posts:list的列表类型键中来获得按照时间顺序排列的文章列表，但是由于列表类型更改元素的顺序比较麻烦，而如今不少博客系统都支持更改文章的发布时间，为了让小白的博客同样支持该功能，我们需要一个新的方案来实现按照时间顺序排列文章的功能。

为了能够自由地更改文章发布时间，可以采用有序集合类型代替列表类型。自然地，元素仍然是文章的ID，而此时元素的分数则是文章发布的Unix时间① 。通过修改元素对应的分数就可以达到更改时间的目的。

注释：①UNIX时间指UTC时间1970年1月1日0时0分0秒起至现在的总秒数（不包括闰秒）。为什么是1970年呢？因为UNIX在1970年左右诞生。

另外借助ZREVRANGEBYSCORE命令还可以轻松获得指定时间范围的文章列表，借助这个功能可以实现类似WordPress的按月份查看文章的功能。

3.6.4 命令拾遗

1．获得集合中元素的数量

ZCARD key

例如：

redis＞ZCARD scoreboard

(integer) 6

2．获得指定分数范围內的元素个数

ZCOUNT key min max

例如：

redis＞ZCOUNT scoreboard 90 100

(integer) 2

ZCOUNT命令的min和max参数的特性与ZRANGEBYSCORE命令中的一样：

redis＞ZCOUNT scoreboard (89 +inf

(integer) 2

3．删除一个或多个元素

ZREM key member [member …]

ZREM命令的返回值是成功删除的元素数量（不包含本来就不存在的元素）。

redis＞ZREM scoreboard Wendy

(integer) 1

redis＞ZCARD scoreboard

(integer) 5

4．按照排名范围删除元素

ZREMRANGEBYRANK key start stop

ZREMRANGEBYRANK命令按照元素分数从小到大的顺序（即索引0表示最小的值）删除处在指定排名范围内的所有元素，并返回删除的元素数量。如：

redis＞ZADD testRem 1 a 2 b 3 c 4 d 5 e 6 f

(integer) 6

redis＞ZREMRANGEBYRANK 0 2

(integer) 3

redis＞ZRANGE testRem 0 -1

1) "d"

2) "e"

3) "f"

5．按照分数范围删除元素

ZREMRANGEBYSCORE key min max

ZREMRANGEBYSCORE命令会删除指定分数范围内的所有元素，参数min和max的特性和ZRANGEBYSCORE命令中的一样。返回值是删除的元素数量。如：

redis＞ZREMRANGEBYSCORE testRem (4 5

(integer) 1

redis＞ZRANGE testRem 0 -1

1) "d"

2) "f"

6．获得元素的排名

ZRANK key member

ZREVRANK key member

ZRANK命令会按照元素分数从小到大的顺序获得指定的元素的排名（从0开始，即分数最小的元素排名为0）。如：

redis＞ZRANK scoreboard Peter

(integer) 0

ZREVRANK命令则相反（分数最大的元素排名为0）：

redis＞ZREVRANK scoreboard Peter

(integer) 4

7．计算有序集合的交集

ZINTERSTORE destination numkeys key [key …] [WEIGHTS weight [weight …]] [AGREGATE SUM|MIN|MAX]

ZINTERSTORE命令用来计算多个有序集合的交集并将结果存储在destination键中（同样以有序集合类型存储），返回值为destination键中的元素个数。

destination键中元素的分数是由AGGREGATE参数决定的。

（1）当AGGREGATE是SUM时（也就是默认值），destination键中元素的分数是每个参与计算的集合中该元素分数的和。例如：

redis＞ZADD sortedSets1 1 a 2 b

(integer) 2

redis＞ZADD sortedSets2 10 a 20 b

(integer) 2

redis＞ZINTERSTORE sortedSetsResult 2 sortedSets1 sortedSets2

(integer) 2

redis＞ZRANGE sortedSetsResult 0 -1 WITHSCORES

1) "a"

2) "11"

3) "b"

4) "22"

（2）当AGGREGATE是MIN时，destination键中元素的分数是每个参与计算的集合中该元素分数的最小值。例如：

redis＞ZINTERSTORE sortedSetsResult 2 sortedSets1 sortedSets2 AGGREGATE MIN

(integer) 2

redis＞ZRANGE sortedSetsResult 0 -1 WITHSCORES

1) "a"

2) "1"

3) "b"

4) "2"

（3）当AGGREGATE是MAX时，destination键中元素的分数是每个参与计算的集合中该元素分数的最大值。例如：

redis＞ZINTERSTORE sortedSetsResult 2 sortedSets1 sortedSets2 AGGREGATE MAX

(integer) 2

redis＞ZRANGE sortedSetsResult 0 -1 WITHSCORES

1) "a"

2) "10"

3) "b"

4)"20"

ZINTERSTORE命令还能够通过WEIGHTS参数设置每个集合的权重，每个集合在参与计算时元素的分数会被乘上该集合的权重。例如：

redis＞ZINTERSTORE sortedSetsResult 2 sortedSets1 sortedSets2 WEIGHTS 1 0.1

(integer) 2

redis＞ZRANGE sortedSetsResult 0 -1 WITHSCORES

1) "a"

2) "2"

3) "b"

4) "4"

另外还有一个命令与ZINTERSTORE命令的用法一样，名为ZUNIONSTORE，它的作用是计算集合间的并集，这里不再赘述。

第4章 进阶

没过几天，小白就完成了博客的开发并将其部署上线。之后的一段时间，小白又使用Redis开发了几个程序，用得还算顺手，便没有继续向宋老师请教Redis的更多知识。直到一个月后的一天，宋老师偶然访问了小白的博客……

本章将会带领读者继续探索Redis，了解Redis的事务、排序与管道等功能，并且还会详细地介绍如何优化Redis的存储空间。

4.1 事务

傍晚时候，忙完了一天的教学工作，宋老师坐在办公室的电脑前开始为明天的课程做准备。尽管有着近5年的教学经验，可是宋老师依然习惯在备课时写一份简单的教案。正在网上查找资料时，在浏览器的历史记录里他突然看到了小白的博客。心想：不知道他的博客怎么样了？

于是宋老师点进了小白的博客，页面刚载入完他就被博客最下面的一行大得夸张的文字吸引了：“Powered by Redis”。宋老师笑了笑，接着就看到了小白博客中最新的一篇文章：

标题：使用Redis来存储微博中的用户关系

正文：在微博中，用户之间是“关注”和“被关注”的关系。如果要使用Redis存储这样的关系可以使用集合类型。思路是对每个用户使用两个集合类型键，分别名为user:用户ID:followers和user:用户ID:following，用来存储关注该用户的用户集合和该用户关注的用户集合。

然后使用一个函数来实现关注操作，伪代码如下：

def follow(currentUser, targetUser)

SADD user:currentUser:following, targetUser

SADD user:targetUser:followers, currentUser

如ID为1的用户A想关注ID为2的用户B，只需要执行follow(1, 2)即可。

然而在实现该功能的时候我发现了一个问题：完成关注操作需要依次执行两条Redis命令，如果在第一条命令执行完后因为某种原因导致第二条命令没有执行，就会出现一个奇怪的现象：A查看自己关注的用户列表时会发现其中有B，而B查看关注自己的用户列表时却没有A，换句话说就是，A虽然关注了B，却不是B的“粉丝”。真糟糕A和B都会对这个网站失望的！但愿不会出现这种情况。

宋老师看到此处，笑得合不拢嘴，把备课的事拋到了脑后。心想：“看来有必要给小白传授一些进阶的知识。”他给小白写了封电子邮件：

其实可以使用Redis的事务来解决这一问题。

4.1.1 概述

Redis中的事务（transaction）是一组命令的集合。事务同命令一样都是Redis的最小执行单位，一个事务中的命令要么都执行，要么都不执行。事务的应用非常普遍，如银行转账过程中A给B汇款，首先系统从A的账户中将钱划走，然后向B的账户增加相应的金额。这两个步骤必须属于同一个事务，要么全执行，要么全不执行。否则只执行第一步，钱就凭空消失了，这显然让人无法接受。

事务的原理是先将属于一个事务的命令发送给Redis，然后再让Redis依次执行这些命令。例如：

redis＞MULTI

OK

redis＞SADD "user:1:following" 2

QUEUED

redis＞SADD "user:2:followers" 1

QUEUED

redis＞EXEC

1) (integer) 1

2) (integer) 1

上面的代码演示了事务的使用方式。首先使用MULTI命令告诉Redis：“下面我发给你的命令属于同一个事务，你先不要执行，而是把它们暂时存起来。”Redis回答：“OK。”

而后我们发送了两个SADD命令来实现关注和被关注操作，可以看到Redis遵守了承诺，没有执行这些命令，而是返回QUEUED表示这两条命令已经进入等待执行的事务队列中了。

当把所有要在同一个事务中执行的命令都发给Redis后，我们使用EXEC命令告诉Redis将等待执行的事务队列中的所有命令（即刚才所有返回QUEUED的命令）按照发送顺序依次执行。EXEC命令的返回值就是这些命令的返回值组成的列表，返回值顺序和命令的顺序相同。

Redis保证一个事务中的所有命令要么都执行，要么都不执行。如果在发送EXEC命令前客户端断线了，则Redis会清空事务队列，事务中的所有命令都不会执行。而一旦客户端发送了EXEC命令，所有的命令就都会被执行，即使此后客户端断线也没关系，因为Redis中已经记录了所有要执行的命令。

除此之外，Redis的事务还能保证一个事务内的命令依次执行而不被其他命令插入。试想客户端A需要执行几条命令，同时客户端B发送了一条命令，如果不使用事务，则客户端B的命令可能会插入到客户端A的几条命令中执行。如果不希望发生这种情况，也可以使用事务。

4.1.2 错误处理

有些读者会有疑问，如果一个事务中的某个命令执行出错，Redis会怎样处理呢？要回答这个问题，首先需要知道什么原因会导致命令执行出错。

（1）语法错误。语法错误指命令不存在或者命令参数的个数不对。比如：

redis＞MULTI

OK

redis＞SET key value

QUEUED

redis＞SET key

(error)ERR wrong number of arguments for 'set' command

redis＞ ERRORCOMMAND key

(error) ERR unknown command 'ERRORCOMMAND'

redis＞ EXEC

(error) EXECABORT Transaction discarded because of previous errors.

跟在MULTI命令后执行了3个命令：一个是正确的命令，成功地加入事务队列；其余两个命令都有语法错误。而只要有一个命令有语法错误，执行EXEC命令后Redis就会直接返回错误，连语法正确的命令也不会执行① 。

注释：①Redis 2.6.5之前的版本会忽略有语法错误的命令，然后执行事务中其他语法正确的命令。就此例而言，SET key value会被执行，EXEC命令会返回一个结果：1) OK。

（2）运行错误。运行错误指在命令执行时出现的错误，比如使用散列类型的命令操作集合类型的键，这种错误在实际执行之前Redis是无法发现的，所以在事务里这样的命令是会被Redis接受并执行的。如果事务里的一条命令出现了运行错误，事务里其他的命令依然会继续执行（包括出错命令之后的命令），示例如下：

redis＞MULTI

OK

redis＞SET key 1

QUEUED

redis＞SADD key 2

QUEUED

redis＞SET key 3

QUEUED

redis＞EXEC

1) OK

2) (error) ERR Operation against a key holding the wrong kind of value

3) OK

redis＞GET key

"3"

可见虽然SADD key 2出现了错误，但是SET key 3依然执行了。

Redis的事务没有关系数据库事务提供的回滚（rollback）① 功能。为此开发者必须在事务执行出错后自己收拾剩下的摊子（将数据库复原回事务执行前的状态等）。

注释：①事务回滚是指将一个事务已经完成的对数据库的修改操作撤销。

不过由于Redis不支持回滚功能，也使得Redis在事务上可以保持简洁和快速。另外回顾刚才提到的会导致事务执行失败的两种错误，其中语法错误完全可以在开发时找出并解决，另外如果能够很好地规划数据库（保证键名规范等）的使用，是不会出现如命令与数据类型不匹配这样的运行错误的。

4.1.3 WATCH命令介绍

我们已经知道在一个事务中只有当所有命令都依次执行完后才能得到每个结果的返回值，可是有些情况下需要先获得一条命令的返回值，然后再根据这个值执行下一条命令。例如，介绍INCR命令时曾经说过使用GET和SET命令自己实现incr函数会出现竞态条件，伪代码如下：

def incr(key)

value=GET key

if not value

value=0

value=value+1

SET key, value

return value

肯定会有很多读者想到可以用事务来实现incr函数以防止竞态条件，可是因为事务中的每个命令的执行结果都是最后一起返回的，所以无法将前一条命令的结果作为下一条命令的参数，即在执行SET命令时无法获得GET命令的返回值，也就无法做到增1的功能了。

为了解决这个问题，我们需要换一种思路。即在GET获得键值后保证该键值不被其他客户端修改，直到函数执行完成后才允许其他客户端修改该键键值，这样也可以防止竞态条件。要实现这一思路需要请出事务家族的另一位成员：WATCH。WATCH命令可以监控一个或多个键，一旦其中有一个键被修改（或删除），之后的事务就不会执行。监控一直持续到EXEC命令（事务中的命令是在EXEC之后才执行的，所以在MULTI命令后可以修改WATCH监控的键值），如：

redis＞SET key 1

OK

redis＞WATCH key

OK

redis＞SET key 2

OK

redis＞MULTI

OK

redis＞SET key 3

QUEUED

redis＞EXEC

(nil)

redis＞GET key

"2"

上例中在执行WATCH命令后、事务执行前修改了key的值（即SET key 2），所以最后事务中的命令SET key 3没有执行，EXEC命令返回空结果。

学会了WATCH命令就可以通过事务自己实现incr函数了，伪代码如下：

def incr(key)

WATCH key

value=GET key

if not value

value=0

value=value+1

MULTI

SET key, value

result=EXEC

return result[0]

因为EXEC命令返回值是多行字符串类型，所以代码中使用result[0]来获得其中第一个结果。

提示  由于WATCH命令的作用只是当被监控的键值被修改后阻止之后一个事务的执行，而不能保证其他客户端不修改这一键值，所以我们需要在EXEC执行失败后重新执行整个函数。

执行EXEC命令后会取消对所有键的监控，如果不想执行事务中的命令也可以使用UNWATCH命令来取消监控。比如，我们要实现hsetxx函数，作用与HSETNX命令类似，只不过是仅当字段存在时才赋值。为了避免竞态条件我们使用事务来完成这一功能：

def hsetxx(key, field, value)

WATCH key

isFieldExists = HEXISTS key, field

if isFieldExists is 1

MULTI

HSET key, field, value

EXEC

else

UNWATCH

return isFieldExists

在代码中会判断要赋值的字段是否存在，如果字段不存在的话就不执行事务中的命令，但需要使用UNWATCH命令来保证下一个事务的执行不会受到影响。

4.2 生存时间

转天早上宋老师就收到了小白的回信，内容基本上都是一些表示感谢的话。宋老师又看了一下小白发的那篇文章，发现他已经在文末补充了使用事务来解决竞态条件的方法。

宋老师单击了评论链接想发表评论，却看到博客出现了错误“请求超时”（Request timeout）。宋老师疑惑了一下，准备稍后再访问看看，就接着忙别的事情了。

没过一会儿，宋老师就收到了一封小白发来的邮件：

宋老师您好！我的博客最近经常无法访问，我看了日志后发现是因为某个搜索引擎爬虫访问得太频繁，加上本来我的服务器性能就不太好，很容易资源就被占满了。请问有没有方法可以限定每个IP地址每分钟最大的访问次数呢？

宋老师这才明白为什么刚才小白的博客请求超时了，于是放下了手头的事情开始继续给小白介绍Redis的更多功能……

4.2.1 命令介绍

在实际的开发中经常会遇到一些有时效的数据，比如限时优惠活动、缓存或验证码等，过了一定的时间就需要删除这些数据。在关系数据库中一般需要额外的一个字段记录到期时间，然后定期检测删除过期数据。而在Redis中可以使用EXPIRE命令设置一个键的生存时间，到时间后Redis会自动删除它。

EXPIRE命令的使用方法为EXPIRE key seconds，其中seconds参数表示键的生存时间，单位是秒。如要想让session:29e3d键在15分钟后被删除：

redis＞SET session:29e3d uid1314

OK

redis＞EXPIRE session:29e3d 900

(integer) 1

EXPIRE命令返回1表示设置成功，返回0则表示键不存在或设置失败。例如：

redis＞DEL session:29e3d

(integer) 1

redis＞EXPIRE session:29e3d 900

(integer) 0

如果想知道一个键还有多久的时间会被删除，可以使用TTL命令。返回值是键的剩余时间（单位是秒）：

redis＞SET foo bar

OK

redis＞EXPIRE foo 20

(integer) 1

redis＞TTL foo

(integer) 15

redis＞TTL foo

(integer) 7

redis＞ TTL foo

(integer) -1

可见随着时间的不同，foo键的生存时间逐渐减少，20秒后foo键会被删除。当键不存在时TTL命令会返回1。另外同样会返回1的情况是没有为键设置生存时间（即永久存在，这是建立一个键后的默认情况）：

redis＞SET persistKey value

OK

redis＞TTL persistKey

(integer) -1

如果想取消键的生存时间设置（即将键恢复成永久的），可以使用PERSIST命令。如果生存时间被成功清除则返回1；否则返回0（因为键不存在或键本来就是永久的）：

redis＞SET foo bar

OK

redis＞EXPIRE foo 20

(integer) 1

redis＞PERSIST foo

(integer) 1

redis＞TTL foo

(integer) -1

除了PERSIST命令之外，使用SET或GETSET命令为键赋值也会同时清除键的生存时间，例如：

redis＞EXPIRE foo 20

(integer) 1

redis＞SET foo bar

OK

redis＞TTL foo

(integer) -1

使用EXPIRE命令会重新设置键的生存时间，就像这样：

redis＞SET foo bar

OK

redis＞EXPIRE foo 20

(integer) 1

redis＞TTL foo

(integer) 15

redis＞EXPIRE foo 20

(integer) 1

redis＞TTL foo

(integer) 17

其他只对键值进行操作的命令（如INCR、LPUSH、HSET、ZREM）均不会影响键的生存时间。

EXPIRE命令的seconds参数必须是整数，所以最小单位是1秒。如果想要更精确的控制键的生存时间应该使用PEXPIRE命令，PEXPIRE命令与EXPIRE的唯一区别是前者的时间单位是毫秒，即PEXPIRE key 1000与EXPIRE key 1等价。对应地可以用PTTL命令以毫秒为单位返回键的剩余时间。

提示  如果使用WATCH命令监测了一个拥有生存时间的键，该键时间到期自动删除并不会被WATCH命令认为该键被改变。

另外还有两个相对不太常用的命令：EXPIREAT 和PEXPIREAT。

EXPIREAT命令与EXPIRE命令的差别在于前者使用Unix时间作为第二个参数表示键的生存时间的截止时间。PEXPIREAT命令与EXPIREAT命令的区别是前者的时间单位是毫秒。例如：

redis＞SET foo bar

OK

redis＞EXPIREAT foo 1351858600

(integer) 1

redis＞TTL foo

(integer) 142

redis＞PEXPIREAT foo 1351858700000

(integer) 1

4.2.2 实现访问频率限制之一

回到小白的问题，为了减轻服务器的压力，需要限制每个用户（以IP计）一段时间的最大访问量。与时间有关的操作很容易想到EXPIRE命令。

例如要限制每分钟每个用户最多只能访问100个页面，思路是对每个用户使用一个名为“rate.limiting:用户IP”的字符串类型键，每次用户访问则使用INCR命令递增该键的键值，如果递增后的值是1（第一次访问页面），则同时还要设置该键的生存时间为1分钟。这样每次用户访问页面时都读取该键的键值，如果超过了100就表明该用户的访问频率超过了限制，需要提示用户稍后访问。该键每分钟会自动被删除，所以下一分钟用户的访问次数又会重新计算，也就达到了限制访问频率的目的。

上述流程的伪代码如下：

isKeyExists=EXISTS rate.limiting:IP

if isKeyExists is 1

times=INCR rate.limiting:IP

if times＞100

print访问频率超过了限制，请稍后再试。

exit

else

INCR rate.limiting:IP

EXPIRE keyName, 60

这段代码存在一个不太明显的问题：假如程序执行完倒数第二行后突然因为某种原因退出了，没能够为该键设置生存时间，那么该键会永久存在，导致使用对应的IP的用户在管理员手动删除该键前最多只能访问100次博客，这是一个很严重的问题。

为了保证建立键和为键设置生存时间一起执行，可以使用上节学习的事务功能，修改后的代码如下：

isKeyExists=EXISTS rate.limiting:IP

if isKeyExists is 1

times=INCR rate.limiting:IP

if times＞100

print访问频率超过了限制，请稍后再试。

exit

else

MULTI

INCR rate.limiting:IP

EXPIRE keyName, 60

EXEC

4.2.3 实现访问频率限制之二

事实上，4.2.2节中的代码仍然有个问题：如果一个用户在一分钟的第一秒访问了一次博客，在同一分钟的最后一秒访问了9次，又在下一分钟的第一秒访问了10次，这样的访问是可以通过现在的访问频率限制的，但实际上该用户在2秒钟内访问了19次博客，这与每个用户每分钟只能访问10次的限制差距较大。尽管这种情况比较极端，但是在一些场合中还是需要粒度更小的控制方案。如果要精确地保证每分钟最多访问10次，需要记录下用户每次访问的时间。因此对每个用户，我们使用一个列表类型的键来记录他最近10次访问博客的时间。一旦键中的元素超过10个，就判断时间最早的元素距现在的时间是否小于1分钟。如果是则表示用户最近1分钟的访问次数超过了10次；如果不是就将现在的时间加入到列表中，同时把最早的元素删除。

上述流程的伪代码如下：

listLength=LLEN rate.limiting:IP

if listLength＜10

LPUSH rate.limiting:IP, now()

else

time=LINDEX rate.limiting:IP, -1

if now()-time＜60

print访问频率超过了限制，请稍后再试。

else

LPUSH rate.limiting:IP, now()

LTRIM rate.limiting:IP, 0, 9

代码中now()的功能是获得当前的Unix时间。由于需要记录每次访问的时间，所以当要限制“A 时间最多访问B次”时，如果“B”的数值较大，此方法会占用较多的存储空间，实际使用时还需要开发者自己去权衡。除此之外该方法也会出现竞态条件，同样可以通过脚本功能避免，具体在第6章会介绍到。

4.2.4 实现缓存

为了提高网站的负载能力，常常需要将一些访问频率较高但是对CPU或IO资源消耗较大的操作的结果缓存起来，并希望让这些缓存过一段时间自动过期。比如教务网站要对全校所有学生的各个科目的成绩汇总排名，并在首页上显示前10名的学生姓名，由于计算过程较耗资源，所以可以将结果使用一个Redis的字符串键缓存起来。由于学生成绩总在不断地变化，需要每隔两个小时就重新计算一次排名，这可以通过给键设置生存时间的方式实现。每次用户访问首页时程序先查询缓存键是否存在，如果存在则直接使用缓存的值；否则重新计算排名并将计算结果赋值给该键并同时设置该键的生存时间为两个小时。伪代码如下：

rank=GET cache:rank

if not rank

rank=计算排名...

MUlTI

SET cache:rank, rank

EXPIRE cache:rank, 7200

EXEC

然而在一些场合中这种方法并不能满足需要。当服务器内存有限时，如果大量地使用缓存键且生存时间设置得过长就会导致Redis占满内存；另一方面如果为了防止Redis占用内存过大而将缓存键的生存时间设得太短，就可能导致缓存命中率过低并且大量内存白白地闲置。实际开发中会发现很难为缓存键设置合理的生存时间，为此可以限制Redis能够使用的最大内存，并让Redis按照一定的规则淘汰不需要的缓存键，这种方式在只将Redis用作缓存系统时非常实用。

具体的设置方法为：修改配置文件的maxmemory参数，限制Redis最大可用内存大小（单位是字节），当超出了这个限制时Redis会依据maxmemory-policy参数指定的策略来删除不需要的键，直到Redis占用的内存小于指定内存。

maxmemory-policy支持的规则如表4-1所示。其中的LRU（Least Recently Used）算法即“最近最少使用”，其认为最近最少使用的键在未来一段时间内也不会被用到，即当需要空间时这些键是可以被删除的。

表4-1  Redis支持的淘汰键的规则

续表

如当maxmemory-policy设置为allkeys-lru时，一旦Redis占用的内存超过了限制值，Redis会不断地删除数据库中最近最少使用的键① ，直到占用的内存小于限制值。

注释：①事实上Redis并不会准确地将整个数据库中最久未被使用的键删除，而是每次从数据库中随机取3个键并删除这3个键中最久未被使用的键。删除生存时间最接近的键的实现方法也是这样。“3”这个数字可以通过Redis的配置文件中的maxmemory-samples参数设置。

4.3 排序

午后，宋老师正在批改学生们提交的程序，再过几天就会迎来第一次计算机全市联考。他在每个学生的程序代码末尾都用注释详细地做了批注——严谨的治学态度让他备受学生们的爱戴。

一个电话打来。“小白的？”宋老师拿出手机，“博客最近怎么样了？”未及小白开口，他就抢先问道。

特别好！现在平均每天都有50多人访问我的博客。不过咋天我收到一个访客的邮件，他向我反映了一个问题：查看一个标签下的文章列表时文章不是按照时间顺序排列的，找起来很麻烦。我看了一下代码，发现程序中是使用SMEMBERS命令获取标签下的文章列表，因为集合类型是无序的，所以不能实现按照文章的发布时间排列。我考虑过使用有序集合类型存储标签，但是有序集合类型的集合操作不如集合类型强大。您有什么好方法来解决这个问题吗？

方法有很多，我推荐使用SORT命令，你先挂了电话，我写好后发邮件给你吧。

4.3.1 有序集合的集合操作

集合类型提供了强大的集合操作命令，但是如果需要排序就要用到有序集合类型。Redis的作者在设计Redis的命令时考虑到了不同数据类型的使用场景，对于不常用到的或者在不损失过多性能的前提下可以使用现有命令来实现的功能，Redis就不会单独提供命令来实现。这一原则使得Redis在拥有强大功能的同时保持着相对精简的命令。

有序集合常见的使用场景是大数据排序，如游戏的玩家排行榜，所以很少会需要获得键中的全部数据。同样Redis认为开发者在做完交集、并集运算后不需要直接获得全部结果，而是会希望将结果存入新的键中以便后续处理。这解释了为什么有序集合只有ZINTERSTORE和ZUNIONSTORE命令而没有ZINTER和ZUNION命令。

当然实际使用中确实会遇到像小白那样需要直接获得集合运算结果的情况，除了等待Redis加入相关命令，我们还可以使用MULTI, ZINTERSTORE, ZRANGE, DEL 和EXEC 这5个命令自己实现ZINTER：

MULTI

ZINTERSTORE tempKey ...

ZRANGE tempKey ...

DEL tempKey

EXEC

4.3.2 SORT命令

除了使用有序集合外，我们还可以借助Redis提供的SORT命令来解决小白的问题。SORT命令可以对列表类型、集合类型和有序集合类型键进行排序，并且可以完成与关系数据库中的连接查询相类似的任务。

小白的博客中标有“ruby”标签的文章的ID分别是：“2”，“6”，“12”，“26”。由于在集合类型中所有元素是无序的，所以使用SMEMBERS命令并不能获得有序的结果① 。为了能够让博客的标签页面下的文章也能按照发布的时间顺序排列（如果不考虑发布后再修改文章发布时间，就是按照文章ID的顺序排列），可以借助SORT命令实现，方法如下所示：

注释：①集合类型经常被用于存储对象的ID，很多情况下都是整数。所以Redis对这种情况进行了特殊的优化，元素的排列是有序的。4.6节会详细介绍具体的原理。

redis＞SORT tag:ruby:posts

1) "2"

2) "6"

3) "12"

4) "26"

是不是十分简单？除了集合类型，SORT命令还可以对列表类型和有序集合类型进行排序：

redis＞LPUSH mylist 4 2 6 1 3 7

(integer)6

redis＞SORT mylist

1) "1"

2) "2"

3) "3"

4) "4"

5) "6"

6) "7"

在对有序集合类型排序时会忽略元素的分数，只针对元素自身的值进行排序。例如：

redis＞ZADD myzset 50 2 40 3 20 1 60 5

(integer) 4

redis＞SORT myzset

1) "1"

2) "2"

3) "3"

4) "5"

除了可以排列数字外，SORT命令还可以通过ALPHA参数实现按照字典顺序排列非数字元素，就像这样：

redis＞LPUSH mylistalpha a c e d B C A

(integer) 7

redis＞SORT mylistalpha

(error) ERR One or more scores can't be converted into double

redis＞SORT mylistalpha ALPHA

1) "A"

2) "B"

3) "C"

4) "a"

5) "c"

6) "d"

7) "e"

从这段示例中可以看到如果没有加ALPHA参数的话，SORT命令会尝试将所有元素转换成双精度浮点数来比较，如果无法转换则会提示错误。

回到小白的问题，SORT命令默认是按照从小到大的顺序排列，而一般博客中显示文章的顺序都是按照时间倒序的，即最新的文章显示在最前面。SORT命令的 DESC参数可以实现将元素按照从大到小的顺序排列：

redis＞SORT tag:ruby:posts DESC

1) "26"

2) "12"

3) "6"

4) "2"

那么如果文章数量过多需要分页显示呢？SORT命令还支持LIMIT参数来返回指定范围的结果。用法和SQL语句一样，LIMIT offset count，表示跳过前offset个元素并获取之后的count个元素。

SORT命令的参数可以组合使用，像这样：

redis＞SORT tag:ruby:posts DESC LIMIT 1 2

1) "12"

2) "6"

4.3.3 BY参数

很多情况下列表（或集合、有序集合）中存储的元素值代表的是对象的ID（如标签集合中存储的是文章对象的ID），单纯对这些ID自身排序有时意义并不大。更多的时候我们希望根据ID对应的对象的某个属性进行排序。回想3.6节，我们通过使用有序集合键来存储文章ID列表，使得小白的博客能够支持修改文章时间，所以文章ID的顺序和文章的发布时间的顺序并不完全一致，因此4.3.2节介绍的对文章ID本身排序就变得没有意义了。小白的博客是使用散列类型键存储文章对象的，其中time字段存储的就是文章的发布时间。现在我们知道ID为“2”，“6”，“12”和“26”的四篇文章的time字段的值分别为“1352619200”，“1352619600”，“1352620100”和“1352620000”（Unix时间）。如果要按照文章的发布时间递减排列结果应为“12”，“26”，“6”，“2”。为了获得这样的结果，需要使用SORT命令的另一个强大的参数——BY。

BY 参数的语法为“BY参考键”。其中参考键可以是字符串类型键或者是散列类型键的某个字段（表示为键名-＞字段名）。如果提供了BY参数，SORT命令将不再依据元素自身的值进行排序，而是对每个元素使用元素的值替换参考键中的第一个“＊”并获取其值，然后依据该值对元素排序。就像这样：

redis＞SORT tag:ruby:posts BY post:＊-＞time DESC

1) "12"

2) "26"

3) "6"

4) "2"

在上例中SORT命令会读取post:2、post:6、post:12、post:26几个散列键中的time字段的值并以此决定tag:ruby:posts键中各个文章ID的顺序。

除了散列类型之外，参考键还可以是字符串类型，比如：

redis＞LPUSH sortbylist 2 1 3

(integer) 3

redis＞SET itemscore:1 50

OK

redis＞SET itemscore:2 100

OK

redis＞SET itemscore:3 -10

OK

redis＞SORT sortbylist BY itemscore:＊ DESC

1) "2"

2) "1"

3) "3"

当参考键名不包含“＊”时（即常量键名，与元素值无关），SORT命令将不会执行排序操作，因为Redis认为这种情况是没有意义的（因为所有要比较的值都一样）。例如：

redis＞SORT sortbylist BY anytext

1) "3"

2) "1"

3) "2"

例子中anytext是常量键名（甚至anytext键可以不存在），此时SORT的结果与LRANGE的结果相同，没有执行排序操作。在不需要排序但需要借助SORT命令获得与元素相关联的数据时（见4.3.4节），常量键名是很有用的。

如果几个元素的参考键值相同，则SORT命令会再比较元素本身的值来决定元素的顺序。像这样：

redis＞LPUSH sortbylist 4

(integer) 4

redis＞SET itemscore:4 50

OK

redis＞SORT sortbylist BY itemscore:＊ DESC

1) "2"

2) "4"

3) "1"

4) "3"

示例中元素“4”的参考键itemscore:4的值和元素“1”的参考键itemscore:1的值都是50，所以SORT命令会再比较“4”和“1”元素本身的大小来决定两者的顺序。

当某个元素的参考键不存在时，会默认参考键的值为0：

redis＞LPUSH sortbylist 5

(integer) 5

redis＞SORT sortbylist BY itemscore:＊ DESC

1) "2"

2) "4"

3) "1"

4) "5"

5) "3"

上例中“5”排在了“3”的前面，是因为“5”的参考键不存在，所以默认为0，而“3”的参考键值为10。

补充知识  参考键虽然支持散列类型，但是“＊”只能在“-＞”符号前面（即键名部分）才有用，在“-＞”后（即字段名部分）会被当成字段名本身而不会作为占位符被元素的值替換，即常量键名。但是实际运行时会发现一个有趣的结果：

redis＞SORT sortbylist BY somekey-＞somefield:＊

1) "1"

2) "2"

3) "3"

4) "4"

5) "5"

上面提到了当参考键名是常量键名时SORT命令将不会执行排序操作，然而上例中确进行了排序，而且只是对元素本身进行排序。这是因为Redis判断参考键名是不是常量键名的方式是判断参考键名中是否包含“＊”，而somekey-＞somefield:＊中包含“＊”所以不是常量键名。所以在排序的时候Redis对每个元素都会读取键somekey中的somefield:＊字段（“＊”不会被替換），无论能否获得其值，每个元素的参考键值是相同的，所以Redis会按照元素本身的大小排列。

4.3.4 GET参数

现在小白的博客已经可以按照文章的发布顺序获得一个标签下的文章ID列表了，接下来要做的事就是对每个ID都使用HGET命令获取文章的标题以显示在博客列表页中。有没有觉得很麻烦？不论你的答案如何，都有一种更简单的方式来完成这个操作，那就是借助SORT命令的GET参数。

GET参数不影响排序，它的作用是使SORT命令的返回结果不再是元素自身的值，而是GET参数中指定的键值。GET参数的规则和BY参数一样，GET参数也支持字符串类型和散列类型的键，并使用“＊”作为占位符。要实现在排序后直接返回ID对应的文章标题，可以这样写：

redis＞SORT tag:ruby:posts BY post:＊-＞time DESC GET post:＊-＞title

1) "Windows 8 app designs"

2) "RethinkDB - An open-source distributed database built with love"

3) "Uses for cURL"

4) "The Nature of Ruby"

在一个SORT命令中可以有多个GET参数（而BY参数只能有一个），所以还可以这样用：

redis＞SORT tag:ruby:posts BY post:＊-＞time DESC GET post:＊-＞title GET post:＊-＞time

1) "Windows 8 app designs"

2) "1352620100"

3) "RethinkDB - An open-source distributed database built with love"

4) "1352620000"

5) "Uses for cURL"

6) "1352619600"

7) "The Nature of Ruby"

8) "1352619200"

可见有N个GET参数，每个元素返回的结果就有N行。这时有个问题：如果还需要返回文章ID该怎么办？答案是使用GET #。就像下面这样：

redis＞SORT tag:ruby:posts BY post:＊-＞time DESC GET post:＊-＞title GET post:＊-＞time GET #

1) "Windows 8 app designs"

2) "1352620100"

3) "12"

4) "RethinkDB - An open-source distributed database built with love"

5) "1352620000"

6) "26"

7) "Uses for cURL"

8) "1352619600"

9) "6"

10) "The Nature of Ruby"

11) "1352619200"

12) "2"

也就是说，GET #会返回元素本身的值。

4.3.5 STORE参数

默认情况下SORT会直接返回排序结果，如果希望保存排序结果，可以使用STORE参数。如希望把结果保存到sort.result键中：

redis＞SORT tag:ruby:posts BY post:＊-＞time DESC GET post:＊-＞title GET post:＊-＞time

GET # STORE sort.result

(integer) 12

redis＞LRANGE sort.result 0 -1

1) "Windows 8 app designs"

2) "1352620100"

3) "12"

4) "RethinkDB - An open-source distributed database built with love"

5) "1352620000"

6) "26"

7) "Uses for cURL"

8) "1352619600"

9) "6"

10) "The Nature of Ruby"

11) "1352619200"

12) "2"

保存后的键的类型为列表类型，如果键已经存在则会覆盖它。加上STORE参数后SORT命令的返回值为结果的个数。

STORE参数常用来结合EXPIRE命令缓存排序结果，如下面的伪代码：

#判断是否存在之前排序结果的缓存

isCacheExists = EXISTS cache.sort

if isCacheExists is 1

#如果存在则直接返回

return LRANGE cache.sort, 0, -1

else

#如果不存在，则使用SORT命令排序并将结果存入cache.sort键中作为缓存

sortResult=SORT some.list STORE cache.sort

#设置缓存的生存时间为10分钟

EXPIRE cache.sort, 600

#返回排序结果

return sortResult

4.3.6 性能优化

SORT是Redis中最强大最复杂的命令之一，如果使用不好很容易成为性能瓶颈。SORT命令的时间复杂度是0(n+mlogm)，其中n表示要排序的列表（集合或有序集合）中的元素个数，m表示要返回的元素个数。当n较大的时候SORT命令的性能相对较低，并且Redis在排序前会建立一个长度为n① 的容器来存储待排序的元素，虽然是一个临时的过程，但如果同时进行较多的大数据量排序操作则会严重影响性能。

注释：①有一个例外是当键类型为有序集合且参考键为常量键名时容器大小为m而不是n。

所以开发中使用SORT命令时需要注意以下几点。

（1）尽可能减少待排序键中元素的数量（使n尽可能小）。

（2）使用LIMIT参数只获取需要的数据（使m尽可能小）。

（3）如果要排序的数据数量较大，尽可能使用STORE参数将结果缓存。

4.4 消息通知

凭着小白的用心经营，博客的访问量逐渐增多，甚至有了小白自己的粉丝。这不，小白刚收到一封来自粉丝的邮件，在邮件中那个粉丝强烈建议小白给博客加入邮件订阅功能，这样当小白发布新文章后订阅小白博客的用户就可以收到通知邮件了。在信的末尾，那个粉丝还着重强调了一下：“这个功能对不习惯使用RSS的用户很重要，希望能够加上！”

看过信后，小白心想：“是个好建议！不过话说回来，似乎他还没发现其实我的博客连RSS功能都没有。”

邮件订阅功能太好实现了，无非是在博客首页放一个文本框供访客输入自己的邮箱地址，提交后博客会将该地址存入Redis的一个集合类型键中（使用集合类型是为了保证同一邮箱地址不会存储多个）。每当发布新文章时，就向收集到的邮箱地址发送通知邮件。

想的简单，可是做出来后小白却发现了一个问题：输入邮箱地址提交后，页面需要很久时间才能载入完。

原来小白为了确保用户没有输入他人的邮箱，在提交之后程序会向用户输入的邮箱发送一封包含确认链接的邮件，只有用户单击这个链接后对应的邮箱地址才会被程序记录。可是由于发送邮件需要连接到一个远程的邮件发送服务器，网络好的情况下也得花上2秒左右的时间，赶上网络不好10秒都必能发完。所以每次用户提交邮箱后页面都要等待程序发送完邮件才能加载出来，而加载出来的页面上显示的内容只是提示用户查看自己的邮箱单击确认链接。“完全可以等页面加载出来后再发送邮件，这样用户就不需要等了。”小白喃喃道。

按照惯例，有问题问宋老师，小白给宋老师发了一封邮件，不久就收到了答复。

4.4.1 任务队列

小白的问题在网站开发中十分常见，当页面需要进行如发送邮件、复杂数据运算等耗时较长的操作时会阻塞页面的渲染。为了避免用户等待太久，应该使用独立的线程来完成这类操作。不过一些编程语言或框架不易实现多线程，这时很容易就会想到通过其他进程来实现。就小白的例子来说，设想有一个进程能够完成发邮件的功能，那么在页面中只需要想办法通知这个进程向指定的地址发送邮件就可以了。

通知的过程可以借助任务队列来实现。任务队列顾名思义，就是“传递任务的队列”。与任务队列进行交互的实体有两类，一类是生产者（producer），一类是消费者（consumer）。生产者会将需要处理的任务放入任务队列中，而消费者则不断地从任务队列中读入任务信息并执行。

对于发邮件这个操作来说页面程序就是生产者，而发邮件的进程就是消费者。当需要发送邮件时，页面程序会将收件地址、邮件主题和邮件正文组装成一个任务后存入任务队列中。同时发邮件的进程会不断检查任务队列，一旦发现有新的任务便会将其从队列中取出并执行。由此实现了进程间的通信。

使用任务队列有如下好处。

（1）松耦合。生产者和消费者无需知道彼此的实现细节，只需要约定好任务的描述格式。这使得生产者和消费者可以由不同的团队使用不同的编程语言编写。

（2）易于扩展消费者可以有多个，而且可以分布在不同的服务器中，如图4-1所示。借此可以轻易地降低单台服务器的负载。

图4-1 可以有多个消费者分配任务队列中的任务

4.4.2 使用Redis实现任务队列

说到队列很自然就能想到Redis的列表类型，3.4.2节介绍了使用LPUSH和RPOP命令实现队列的概念。如果要实现任务队列，只需要让生产者将任务使用LPUSH命令加入到某个键中，另一边让消费者不断地使用RPOP命令从该键中取出任务即可。

在小白的例子中，完成发邮件的任务需要知道收件地址、邮件主题和邮件正文。所以生产者需要将这三个信息组成对象并序列化成字符串，然后将其加入到任务队列中。而消费者则循环从队列中拉取任务，就像如下伪代码：

#无限循环读取任务队列中的内容

loop

task=RPOR queue

if task

#如果任务队列中有任务则执行它

execute(task)

else

#如果没有则等待1秒以免过于频繁地请求数据

wait 1 second

到此一个使用Redis实现的简单的任务队列就写好了。不过还有一点不完美的地方：当任务队列中没有任务时消费者每秒都会调用一次RPOP命令查看是否有新任务。如果可以实现一旦有新任务加入任务队列就通知消费者就好了。其实借助 BRPOP 命令就可以实现这样的需求。

BRPOP命令和RPOP命令相似，唯一的区别是当列表中没有元素时BRPOP命令会一直阻塞住连接，直到有新元素加入。如上段代码可改写为：

loop

#如果任务队列中没有新任务，BRPOP 命令会一直阻塞，不会执行execute()。

task=BRPOP queue, 0

#返回值是一个数组（见下介绍），数组第二个元素是我们需要的任务。

execute(task[1])

BRPOP命令接收两个参数，第一个是键名，第二个是超时时间，单位是秒。当超过了此时间仍然没有获得新元素的话就会返回nil。上例中超时时间为“0”，表示不限制等待的时间，即如果没有新元素加入列表就会永远阻塞下去。

当获得一个元素后BRPOP命令返回两个值，分别是键名和元素值。为了测试BRPOP命令，我们可以打开两个redis-cli实例，在实例A中：

redis A＞BRPOP queue 0

键入回车后实例1会处于阻塞状态，这时在实例B中向queue中加入一个元素：

redis B＞LPUSH queue task

(integer) 1

在LPUSH命令执行后实例A马上就返回了结果：

1) "queue"

2) "task"

同时会发现queue中的元素已经被取走：

redis＞LLEN queue

(integer) 0

除了BRPOP命令外，Redis还提供了BLPOP，和BRPOP的区别在与从队列取元素时BLPOP会从队列左边取。具体可以参照LPOP理解，这里不再赘述。

4.4.3 优先级队列

前面说到了小白博客需要在发布文章的时候向每个订阅者发送邮件，这一步骤同样可以使用任务队列实现。由于要执行的任务和发送确认邮件一样，所以二者可以共用一个消费者。然而设想这样的情况：假设订阅小白博客的用户有1000人，那么当发布一篇新文章后博客就会向任务队列中添加1000个发送通知邮件的任务。如果每发一封邮件需要10秒，全部完成这1000个任务就需要近3个小时。问题来了，假如这期间有新的用户想要订阅小白博客，当他提交完自己的邮箱并看到网页提示他查收确认邮件时，他并不知道向自己发送确认邮件的任务被加入到了已经有1000个任务的队列中。要收到确认邮件，他不得不等待近3个小时。多么糟糕的用户体验！而另一方面发布新文章后通知订阅用户的任务并不是很紧急，大多数用户并不要求有新文章后马上就能收到通知邮件，甚至延迟一天的时间在很多情况下也是可以接受的。

所以可以得出结论当发送确认邮件和发送通知邮件两种任务同时存在时，应该优先执行前者。为了实现这一目的，我们需要实现一个优先级队列。

BRPOP命令可以同时接收多个键，其完整的命令格式为BLPOP key [key …]timeout，如BLPOP queue:1 queue:2 0。意义是同时检测多个键，如果所有键都没有元素则阻塞，如果其中有一个键有元素则会从该键中弹出元素。例如，打开两个redis-cli实例，在实例A中：

redis A＞BLPOP queue:1 queue:2 queue:3 0

在实例B中：

redis B＞LPUSH queue:2 task

(integer) 1

则实例A中会返回：

1) "queue:2"

2) "task"

如果多个键都有元素则按照从左到右的顺序取第一个键中的一个元素。我们先在queue:2和queue:3中各加入一个元素：

redis＞LPUSH queue:2 task1

1) (integer) 1

redis＞LPUSH queue:3 task2

2) (integer) 1

然后执行BRPOP命令：

redis＞BRPOP queue:1 queue:2 queue:3 0

1) "queue:2"

2) "task1"

借此特性可以实现区分优先级的任务队列。我们分别使用queue:confirmation.email和queue:notification.email两个键存储发送确认邮件和发送通知邮件两种任务，然后将消费者的代码改为：

loop

task =

BRPOP queue:confirmation.email,

queue:notification.email,

0

execute(task[1])

这时一旦发送确认邮件的任务被加入到queue:confirmation.email队列中，无论queue: notification.email还有多少任务，消费者都会优先完成发送确认邮件的任务。

4.4.4 “发布/订阅”模式

除了实现任务队列外，Redis还提供了一组命令可以让开发者实现“发布/订阅”（publish/subscribe）模式。“发布/订阅”模式同样可以实现进程间的消息传递，其原理是这样的：

“发布/订阅”模式中包含两种角色，分别是发布者和订阅者。订阅者可以订阅一个或若干个频道（channel），而发布者可以向指定的频道发送消息，所有订阅此频道的订阅者都会收到此消息。

发布者发布消息的命令是PUBLISH，用法是PUBLISH channel message，如向channel.1说一声“hi”：

redis＞PUBLISH channel.1 hi

(integer) 0

这样消息就发出去了。PUBLISH命令的返回值表示接收到这条消息的订阅者数量。因为此时没有客户端订阅channel.1，所以返回0。发出去的消息不会被持久化，也就是说当有客户端订阅channel.1后只能收到后续发布到该频道的消息，之前发送的就收不到了。

订阅频道的命令是SUBSCRIBE，可以同时订阅多个频道，用法是SUBSCRIBE channel[channel …]。现在新开一个redis-cli实例A，用它来订阅channel.1：

redis A＞SUBSCRIBE channel.1

Reading messages... (press Ctrl-C to quit)

1) "subscribe"

2) "channel.1"

3) (integer) 1

执行SUBSCRIBE命令后客户端会进入订阅状态，处于此状态下客户端不能使用除SUBSCRIBE/UNSUBSCRIBE/PSUBSCRIBE/PUNSUBSCRIBE这4个属于“发布/订阅”模式的命令之外的命令（后面3个命令会在下面介绍），否则会报错。

进入订阅状态后客户端可能收到三种类型的回复。每种类型的回复都包含3个值，第一个值是消息的类型，根据消息类型的不同，第二、三个值的含义也不同。消息类型可能的取值有：

（1）Subscribe。表示订阅成功的反馈信息。第二个值是订阅成功的频道名称，第三个值是当前客户端订阅的频道数量。

（2）message。这个类型的回复是我们最关心的，它表示接收到的消息。第二个值表示产生消息的频道名称，第三个值是消息的内容。

（3）unsubscribe。表示成功取消订阅某个频道。第二个值是对应的频道名称，第三个值是当前客户端订阅的频道数量，当此值为0时客户端会退出订阅状态，之后就可以执行其他非“发布/订阅”模式的命令了。

上例中当实例A订阅了channel.1进入订阅状态后收到了一条subscribe类型的回复，这时我们打开另一个redis-cli实例B，并向channel.1发送一条消息：

redis B＞PUBLISH channel.1 hi!

(integer) 1

返回值为1表示有一个客户端订阅了channel.1，此时实例A 收到了类型为message的回复：

1) "message"

2) "channel.1"

3) "hi!"

使用UNSUBSCRIBE命令可以取消订阅指定的频道，用法为UNSUBSCRIBE [channel[channel …]]，如果不指定频道则会取消订阅所有频道① 。

注释：①由于redis-cli的限制我们无法在其中测试UNSUBSCRIBE命令。

4.4.5 按照规则订阅

除了可以使用SUBSCRIBE命令订阅指定名称的频道外，还可以使用PSUBSCRIBE命令订阅指定的规则。规则支持glob风格通配符格式（见3.1节），下面我们新打开一个redis-cli实例C进行演示：

redis C＞PSUBSCRIBE channel.?＊

Reading messages... (press Ctrl-C to quit)

1) "psubscribe"

2) "channel.?＊"

3) (integer) 1

规则channel.?＊可以匹配channel.1和channel.10，但不会匹配channel.。这时在实例B中发布消息：

redis B＞PUBLISH channel.1 hi!

(integer) 2

返回结果为2是因为实例A和实例C两个客户端都订阅了channel.1频道。实例C接收到的回复是：

1) "pmessage"

2) "channel.?＊"

3) "channel.1"

4) "hi!"

第一个值表示这条消息是通过PSUBSCRIBE命令订阅频道而收到的，第二个值表示订阅时使用的通配符，第三个值表示实际收到消息的频道命令，第四个值则是消息内容。

提示  使用PSUBSCRIBE命令可以重复订阅一个频道，如某客户端执行了PSUBSCRIBE channel.? channel.?*，这时向channel.2发布消息后该客户端会收到两条消息，而同时PUBLISH命令返回的值也是2而不是1。同样的，如果有另一个客户端执行了SUBSCRIBE channel.10，和PSUBSCRIBE channel.?*的话，向channel.10发送命令该客户端也会收到两条消息（但是是两种类型，message 和pmessage），同时PUBLISH命令会返回2。

PUNSUBSCRIBE命令可以退订指定的规则，用法是PUNSUBSCRIBE [pattern[pattern …]]，如果没有参数则会退订所有规则。

注意  使用PUNSUBSCRIBE命令只能退订通过PSUBSCRIBE命令订阅的规则，不会影响直接通过SUBSCRIBE命令订阅的频道；同样UNSUBSCRIBE命令也不会影响通过PSUBSCRIBE命令订阅的规则。另外容易出错的一点是使用PUNSUBSCRIBE命令退订某个规则时不会将其中的通配符展开，而是进行严格的字符串匹配，所以PUNSUBSCRIBE*无法退订channel.*规则，而是必须使用PUNSUBSCRIBE channel.*才能退订。

4.5 管道

客户端和Redis使用TCP协议连接。不论是客户端向Redis发送命令还是Redis向客户端返回命令的执行结果，都需要经过网络传输，这两个部分的总耗时称为往返时延。根据网络性能不同，往返时延也不同，大致来说到本地回环地址（loop backaddress）的往返时延在数量级上相当于Redis处理一条简单命令（如LPUSH list 1 2 3）的时间。如果执行较多的命令，每个命令的往返时延累加起来对性能还是有一定影响的。

在执行多个命令时每条命令都需要等待上一条命令执行完（即收到Redis的返回结果）才能执行，即使命令不需要上一条命令的执行结果。如要获得post:1、post:2和post:3这3个键中的title字段，需要执行三条命令，如图4-2所示。

图4-2 不使用管道时的命令执行示意图（纵向表示时间）

Redis的底层通信协议对管道（pipelining）提供了支持。通过管道可以一次性发送多条命令并在执行完后一次性将结果返回，当一组命令中每条命令都不依赖于之前命令的执行结果时就可以将这组命令一起通过管道发出。管道通过减少客户端与Redis的通信次数来实现降低往返时延累计值的目的，如图4-3所示。

图4-3 使用管道时的命令执行示意图

4.6 节省空间

Jim Gray① 曾经说过：“内存是新的硬盘，硬盘是新的磁带。”内存的容量越来越大，价格也越来越便宜。2012年年底，亚马逊宣布即将发布一个拥有240GB内存的EC2实例，如果放到若干年前来看，这个容量就算是对于硬盘来说也是很大的了。即便如此，相比于硬盘而言，内存在今天仍然显得比较昂贵。而Redis是一个基于内存的数据库，所有的数据都存储在内存中，所以如何优化存储，减少内存空间占用对成本控制来说是一个非常重要的话题。

注释：①Jim Gray是1998年的图灵奖得主，在数据库（尤其是事务）方面做出过卓越的贡献。其于2007年独自驾船在海上失踪。

4.6.1 精简键名和键值

精简键名和键值是最直观的减少内存占用的方式，如将键名very.important.person:20改成VIP:20。当然精简键名一定要把握好尺度，不能单纯为了节约空间而使用不易理解的键名（比如将VIP:20修改为V:20，这样既不易维护，还容易造成命名沖突）。又比如一个存储用户性别的字符串类型键的取值是male和female，我们可以将其修改成m和f来为每条记录节约几个字节的空间（更好的方法是使用0和1来表示性别，稍后会详细介绍原因）① 。

注释：①3.2.4节还介绍过使用字符串类型的位操作来存储性别，更加节约空间。

4.6.2 内部编码优化

有时候仅凭精简键名和键值所减少的空间并不足以满足需求，这时就需要根据Redis内部编码规则来节省更多的空间。Redis为每种数据类型都提供了两种内部编码方式，以散列类型为例，散列类型是通过散列表实现的，这样就可以实现0(1)时间复杂度的查找、赋值操作，然而当键中元素很少的时候，0(1)的操作并不会比0(n)有明显的性能提高，所以这种情况下Redis会采用一种更为紧凑但性能稍差（获取元素的时间复杂度为0(n)）的内部编码方式。内部编码方式的选择对于开发者来说是透明的，Redis会根据实际情况自动调整。当键中元素变多时Redis会自动将该键的内部编码方式转换成散列表。如果想查看一个键的内部编码方式可以使用OBJECT ENCODING命令，例如：

redis＞SET foo bar

OK

redis＞OBJECT ENCODING foo

"raw"

Redis的每个键值都是使用一个redisObject结构体保存的，redisObject的定义如下：

typedef struct redisObject {

unsigned type:4;

unsigned notused:2; /＊ Not used ＊/

unsigned encoding:4;

unsigned lru:22; /＊ lru time (relative to server.lruclock) ＊/

int refcount;

void ＊ptr;

}robj;

其中type字段表示的是键值的数据类型，取值可以是如下内容：

#define REDIS_STRING 0

#define REDIS_LIST 1

#define REDIS_SET 2

#define REDIS_ZSET 3

#define REDIS_HASH 4

encoding字段表示的就是Redis键值的内部编码方式，取值可以是：

#define REDIS_ENCODING_RAW 0 /＊ Raw representation ＊/

#define REDIS_ENCODING_INT 1 ed as integer ＊/

#define REDIS_ENCODING_HT 2 /＊ Encoded as hash table ＊/

#define REDIS_ENCODING_ZIPMAP 3 /＊ Encoded as zipmap ＊/

#define REDIS_ENCODING_LINKEDLIST 4 /＊ Encoded as regular linked list ＊/

#define REDIS_ENCODING_ZIPLIST 5 /＊ Encoded as ziplist ＊/

#define REDIS_ENCODING_INTSET 6 /＊ Encoded as intset ＊/

#define REDIS_ENCODING_SKIPLIST 7 /＊ Encoded as skiplist ＊/

各个数据类型可能采用的内部编码方式以及相应的OBJECT ENCODING命令执行结果如表4-2所示。

表4-2  每个数据类型都可能采用两种内部编码方式之一来存储

下面针对每种数据类型分别介绍其内部编码规则及优化方式。

1．字符串类型

Redis使用一个sdshdr类型的变量来存储字符串，而redisObject的ptr字段指向的是该变量的地址。sdshdr的定义如下：

struct sdshdr {

int len;

int free;

char buf[];

};

其中len字段表示的是字符串的长度，free字段表示buf中的剩余空间，而buf字段存储的才是字符串的内容。

所以当执行SET key foobar时，存储键值需要占用的空间是sizeof(redisObject)+sizeof(sdshdr)+strlen("foobar")=30字节① ，如图4-4所示。

注释：①本节所说的字节数以64位Linux系统为前提。

图4-4 字符串键值“foobar”的存储结构

而当键值内容可以用一个64位有符号整数表示时，Redis会将键值转换成long类型来存储。如SET key 123456，实际占用的空间是sizeof(redisObject)=16字节，比存储"foobar"节省了一半的存储空间，如图4-5所示。

图4-5 字符串键值“123456”的内存结构

redisObject中的refcount字段存储的是该键值被引用数量，即一个键值可以被多个键引用。Redis启动后会预先建立10000个分别存储从0到9999这些数字的redisObject类型变量作为共享对象，如果要设置的字符串键值在这10000个数字内（如SET key1 123）则可以直接引用共享对象而不用再建立一个redisObject了，也就是说存储键值占用的空间是0字节，如图4-6所示。

图4-6 当执行了SET key1 123和SET key2 123后，key1和key2两个键都直接引用了一个已经建立好的共享对象，节省了存储空间

由此可见，使用字符串类型键存储对象ID这种小数字是非常节省存储空间的，Redis只需存储键名和一个对共享对象的引用即可。

提示  当通过配置文件参数maxmemory设置了Redis可用的最大空间大小时，Redis不会使用共享对象，因为对于每一个键值都需要使用一个redisObject来记录其LRU信息。

2．散列类型

散列类型的内部编码方式可能是REDIS_ENCODING_HT或 REDIS_ENCODING_ZIPLIST① 。在配置文件中可以定义使用REDIS_ENCODING_ZIPLIST方式编码散列类型的时机：

注释：①在Redis 2.4及以前的版本中散列类型的键采用REDIS_ENCODING_HT或REDIS_ENCODING_ZIPMAP的编码方式。

hash-max-ziplist-entries 512

hash-max-ziplist-value 64

当散列类型键的字段个数少于hash-max-ziplist-entries参数值且每个字段名和字段值的长度都小于hash-max-ziplist-value参数值（单位为字节）时，Redis就会使用REDIS_ ENCODING_ZIPLIST来存储该键，否则就会使用REDIS_ENCODING_HT。转换过程是透明的，每当键值变更后Redis都会自动判断是否满足条件来完成转换。

REDIS_ENCODING_HT编码即散列表，可以实现O(1)时间复杂度的赋值取值等操作，其字段和字段值都是使用redisObject存储的，所以前面讲到的字符串类型键值的优化方法同样适用于散列类型键的字段和字段值。

提示  Redis的键值对存储也是通过散列表实现的，与REDIS_ENCODING_HT编码方式类似，但键名并非使用redisObject存储，所以键名“123456”并不会比“abcdef”占用更少的空间。之所以不对键名进行优化是因为绝大多数情况下键名都不会是纯数字。

补充知识  Redis支持多数据库，每个数据库中的数据都是通过结构体redisDb存储的。redisDb的定义如下：

typedef struct redisDb {

dict ＊dict; /＊ The keyspace for this DB ＊/

dict ＊expires; /＊ Timeout of keys with a timeout set ＊/

dict ＊blocking_keys; /＊ Keys with clients waiting for data (BLPOP) ＊/

dict ＊ready_keys; /＊ Blocked keys that received a PUSH ＊/

dict ＊watched_keys; /＊ WATCHED keys for MULTI/EXEC CAS ＊/

int id;

} redisDb;

dict类型就是散列表结构，expires存储的是数据的过期时间。当Redis启动时会根据配置文件中databases参数指定的数量创建若干个redisDb类型变量存储不同数据库中的数据。

REDIS_ENCODING_ZIPLIST编码类型是一种紧凑的编码格式，它牺牲了部分读取性能以换取极高的空间利用率，适合在元素较少时使用。该编码类型同样还在列表类型和有序集合类型中使用。REDIS_ENCODING_ZIPLIST编码结构如图4-7所示，其中zlbytes是uint32_t类型，表示整个结构占用的空间。zltail也是uint32_t类型，表示到最后一个元素的偏移，记录zltail使得程序可以直接定位到尾部元素而无需遍历整个结构，执行从尾部弹出（对列表类型而言）等操作时速度更快。zllen是uint16_t类型，存储的是元素的数量。zlend是一个单字节标识，标记结构的末尾，值永远是255。

图4-7 REDIS_ENCODING_ZIPLIST编码的内存结构

在REDIS_ENCODING_ZIPLIST 中每个元素由4个部分组成。

第一个部分用来存储前一个元素的大小以实现倒序查找，当前一个元素的大小小于254字节时第一个部分占用1个字节，否则会占用5个字节。

第二、三个部分分别是元素的编码类型和元素的大小，当元素的大小小于或等于63个字节时，元素的编码类型是ZIP_STR_06B（即0＜＜6），同时第三个部分用6个二进制位来记录元素的长度，所以第二、三个部分总占用空间是1字节。当元素的大小大于63且小于或等于16383字节时，第二、三个部分总占用空间是2字节。当元素的大小大于16383字节时，第二、三个部分总占用空间是5字节。

第四个部分是元素的实际内容，如果元素可以转换成数字的话Redis会使用相应的数字类型来存储以节省空间，并用第二、三个部分来表示数字的类型（int16_t、int32_t等）。

使用REDIS_ENCODING_ZIPLIST编码存储散列类型时元素的排列方式是：元素1存储字段1，元素2存储字段值1，依次类推，如图4-8所示。

图4-8 使用REDIS_ENCODING_ZIPLIST编码存储散列类型的内存结构

例如，当执行命令HSET hkey foo bar命令后，hkey键值的内存结构如图4-9所示。

图4-9 hkey键值的内存结构

下次需要执行HSET hkey foo anothervalue时Redis需要从头开始找到值为foo的元素（查找时每次都会跳过一个元素以保证只查找字段名），找到后删除其下一个元素，并将新值anothervalue插入。删除和插入都需要移动后面的内存数据，而且查找操作也需要遍历才能完成，可想而知当散列键中数据多时性能将很低，所以不宜将hash-max-ziplist-entries和hash-max-ziplist-value两个参数设置得很大。

3．列表类型

列表类型的内部编码方式可能是REDIS_ENCODING_LINKEDLIST或REDIS ENCODINGZIPLIST。同样在配置文件中可以定义使用REDIS_ENCODING_ZIPLIST方式编码的时机：

list-max-ziplist-entries 512

list-max-ziplist-value 64

具体转换方式和散列类型一样，这里不再赘述。

REDIS_ENCODING_LINKEDLIST编码方式即双向链表，链表中的每个元素是用redisObject存储的，所以此种编码方式下元素值的优化方法与字符串类型的键值相同。

而使用REDIS_ENCODING_ZIPLIST编码方式时具体的表现和散列类型一样，由于REDIS_ENCODING_ZIPLIST编码方式同样支持倒序访问，所以采用此种编码方式时获取两端的数据依然较快。

4．集合类型

集合类型的内部编码方式可能是REDIS_ENCODING_HT或REDIS_ENCODING_INTSET。当集合中的所有元素都是整数且元素的个数小于配置文件中的set-max-intset-entries参数指定值（默认是512）时Redis会使用REDIS_ENCODING_INTSET编码存储该集合，否则会使用REDIS_ENCODING_HT来存储。

REDIS_ENCODING_INTSET编码存储结构体intset的定义是：

typedef struct intset {

uint32_t encoding;

uint32_t length;

int8_t contents[];

} intset;

其中contents存储的就是集合中的元素值，根据encoding的不同，每个元素占用的字节大小不同。默认的encoding是INTSET_ENC_INT16（即2个字节），当新增加的整数元素无法使用2个字节表示时，Redis会将该集合的encoding升级为INTSET_ENC_INT32（即4个字节）并调整之前所有元素的位置和长度，同样集合的encoding还可升级为INTSET_ENC_INT64（即8个字节）。

REDIS_ENCODING_INTSET编码以有序的方式存储元素（所以使用SMEMBERS命令获得的结果是有序的），使得可以使用二分算法查找元素。然而无论是添加还是删除元素，Redis都需要调整后面元素的内存位置，所以当集合中的元素太多时性能较差。

当新增加的元素不是整数或集合中的元素数量超过了set-max-intset-entries参数指定值时，Redis会自动将该集合的存储结构转换成REDIS_ENCODING_HT。

注意  当集合的存储结构转換成REDIS_ENCODING_HT后，即使将集合中的所有非整数元素删除，Redis也不会自动将存储结构转換回REDIS_ENCODING_INTSET。因为如果要支持自动回转，就意味着Redis在每次删除元素时都需要遍历集合中的键来判断是否可以转換回原来的编码，这会使得删除元素变成了时间复杂度为0(n)的操作。

5．有序集合类型

有序集合类型的内部编码方式可能是REDIS_ENCODING_SKIPLIST或REDIS_ENCODING_ZIPLIST。同样在配置文件中可以定义使用REDIS_ENCODING_ZIPLIST方式编码的时机：

zset-max-ziplist-entries 128

zset-max-ziplist-value 64

具体规则和散列类型及列表类型一样，不再赘述。

当编码方式是REDIS_ENCODING_SKIPLIST时，Redis使用散列表和跳跃列表（skiplist）两种数据结构来存储有序集合类型键值，其中散列表用来存储元素值与元素分数的映射关系以实现0(1)时间复杂度的ZSCORE等命令。跳跃列表用来存储元素的分数及其到元素值的映射以实现排序的功能。Redis对跳跃列表的实现进行了几点修改，其中包括允许跳跃列表中的元素（即分数）相同，还有为跳跃链表每个节点增加了指向前一个元素的指针以实现倒序查找。

采用此种编码方式时，元素值是使用redisObject存储的，所以可以使用字符串类型键值的优化方式优化元素值，而元素的分数是使用double类型存储的。

使用REDIS_ENCODING_ZIPLIST编码时有序集合存储的方式按照“元素1的值，元素1的分数，元素2的值，元素2的分数”的顺序排列，并且分数是有序的。

第5章 实践

小白把宋老师向自己讲解的知识总结成了一篇帖子发在了学校的网站上，引起了强烈的反响。很多同学希望宋老师能够再写一些关于Redis实践方面的教程，宋老师爽快地答应了。

在此之前我们进行的操作都是通过Redis的命令行客户端redis-cli进行的，并没有介绍实际编程时如何操作Redis。本章将会通过4个实例分别介绍Redis的PHP、Python、Ruby和Node.js客户端的使用方法，即使你不了解其中的某些语言，粗浅的阅读一下也能收获很多实践方面的技巧。

5.1 PHP与Redis

Redis官方推荐的PHP客户端是Predis① 和phpredis② 。前者是完全使用PHP代码实现的原生客户端，而后者则是使用C语言编写的PHP扩展。在功能上两者区别并不大，就性能而言后者会更胜一筹。考虑到很多主机并未提供安装PHP扩展的权限，本节会以Predis为示例介绍如何在PHP中使用Redis。

注释：①见https://github.com/nrk/predis。

注释：②见https://github.com/nicolasff/phpredis。

虽然Predis的性能逊于phpredis，但是除非执行大量Redis命令，否则很难区分二者的性能。而且实际应用中执行Redis命令的开销更多在网络传输上，单纯注重客户端的性能意义不大。读者在开发时可以根据自己的项目需要来权衡使用哪个客户端。

Predis对PHP 版本的最低要求为5.3。

5.1.1 安装

安装Predis可以克隆其版本库（git clone git://github.com/nrk/predis.git），也可以直接从GitHub项目主页中下载代码的ZIP压缩包。如目前最新版v0.8.1的下载地址为https://github.com/nrk/predis/archive/v0.8.1.zip。下载后解压并将整个文件夹复制到项目目录中即可使用。

使用时首先需要引入autoload.php文件：

require './predis/autoload.php';

Predis使用了PHP 5.3中的命名空间特性，并支持PSR-0标准① 。autoload.php文件通过定义PHP的自动加载函数实现了该标准，所以引入了autoload.php文件后就可以自动根据命名空间和类名来自动载入相应的文件了。例如：

注释：①PSR-0标准由PHP Framework Interoperability Group确定，其定义了PHP命名空间与文件路径的对应关系。该标准的网址为https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-0.md。

redis = new Predis\Client();

会自动加载Predis目录下的Client.php文件。如果你的项目使用的PHP框架已经支持了这一标准那么就无需再次引入autoload.php了。

5.1.2 使用方法

首先创建一个到Redis的连接：

redis=new Predis\Client();

该行代码会默认Redis的地址为127.0.0.1，端口为6379。如果需要更改地址或端口，可以使用：

redis=new Predis\Client(array(

'scheme'=＞'tcp',

'host'=＞'127.0.0.1',

'port'=＞6379,

));

作为开始，我们首先使用GET 命令作为测试：

echo redis-＞get('foo');

该行代码获得了键名为foo的字符串类型键的值并输出出来，如果不存在则会返回NULL。

当foo键的类型不是字符串类型（如列表类型）时会报异常，可以为该行代码加上异常处理：

try{

echo redis-＞get('foo');

}catch (Exception e){

echo "Message: {e-＞getMessage()}";

}

这时输出的内容为：“Message: ERR Operation against a key holding thewrong kind of value”。

调用其他命令的方法和GET命令一样，如要执行LPUSH numbers 1 2 3：

redis-＞lpush('numbers', '1', '2', '3');

5.1.3 简便用法

为了使开发更方便，Predis为许多命令额外提供了简便用法，这里选择几个典型的用法依次介绍。

1．MGET/MSET

Predis调用MSET命令时支持将PHP的关联数组直接作为参数，就像这样：

userName=array(

'user:1:name'=＞'Tom',

'user:2:name'=＞'Jack'

);

//相当于redis-＞mset('user:1:name','Tom','user:2:name','Jack');

redis-＞mset(userName);

同样MGET命令支持一个数组作为参数：

users=array_keys(userName);

print_r(redis-＞mget(users));

打印的结果为：

Array

(

[0]=＞Tom

[1]=＞Jack

)

2．HMSET/HMGET/HGETALL

Predis调用HMSET的方式和MSET类似，如：

user1=array(

'name'=＞'Tom',

'age'=＞'32'

);

redis-＞hmset('user:1', user1);

HMGET与MGET类似，不再赘述。最方便的是HGETALL命令，Predis会将Redis返回的结果组装成关联数组返回：

user=redis-＞hgetall('user:1');

echo user['name'];//'Tom'

3．LPUSH/SADD/ZADD

LPUSH和SADD的调用方式类似：

items=array('a','b');

//相当于redis-＞lpush('list','a','b');

redis-＞lpush('list',items);

//相当于redis-＞sadd('set','a','b');

redis-＞sadd('set',items);

而ZADD的调用方式为：

itemScore=array('Tom'=＞'100'

'Jack'=＞'89'

);

//相当于redis-＞zadd('zset', '100', 'Tom', '89', 'Jack');

redis-＞zadd('zset', itemScore);

4．SORT

在Predis中调用SORT命令的方式和其他命令不同，必须将SORT命令中除键名外的参数作为关联数组传入到函数中。如对SORT mylist BY weight_＊ LIMIT 010 GETvalue_＊ GET # ASC ALPHA STORE result这条命令而言，使用Predis的调用方法如下：

redis-＞sort('mylist', array(

'by'=＞'weight_＊',

'limit'=＞array(0, 10),

'get'=＞array('value_＊', '#'),

'sort'=＞'asc',

'alpha'=＞true,

'store'=＞'result'

));

5.1.4 实践：用户注册登录功能

本节将使用PHP和Redis实现用户注册登录功能，下面分模块来介绍具体实现方法。

1．注册

需求描述：用户注册时需要提交邮箱、登录密码和昵称。其中邮箱是用户的唯一标识，每个用户的邮箱不能重复，但允许用户修改自己的邮箱。

我们使用散列类型来存储用户的资料，键名为user:用户ID。其中用户ID是一个自增的数字，之所以使用ID而不是邮箱作为用户的标识是因为考虑到在其他键中可能会通过用户的标识与用户对象相关联，如果使用邮箱作为用户的标识的话在用户修改邮箱时就不得不同时需要修改大量的键名或键值。为了尽可能地减少要修改的地方，我们只把邮箱作为该散列键的一个字段。为此还需要使用一个散列类型的键email.to.id来记录邮箱和用户ID间的对应关系以便在登录时能够通过邮箱获得用户的ID。

用户填写并提交注册表单后首先需要验证用户输入，我们在项目目录中建立一个register.php文件来实现用户注册的逻辑。验证部分的代码如下：

//设置Content-type以使浏览器可以使用正确的编码显示提示信息，

//具体的编码需要根据文件实际编码选择，此处是utf-8。

header("Content-type: text/html; charset=utf-8");

if(!isset(_POST['email']) ||

!isset(_POST['password']) ||

!isset(_POST['nickname'])){

echo '请填写完整的信息。';

exit;

}

email=_POST['email'];

//验证用户提交的邮箱是否正确

if(!filter_var(email, FILTER_VALIDATE_EMAIL)){

echo'邮箱格式不正确，请重新检查';

exit;

}

rawPassword=_POST['password'];

//验证用户提交的密码是否安全

if(strlen(rawPassword)＜ 6){

echo '为了保证安全，密码长度至少为6。';

exit;

}

nickname=_POST['nickname'];

//不同的网站对用户昵称有不同的要求，这里不再做检查，即使是空也可以。

//而后我们需要判断用户提交的邮箱是否被注册了：

redis=new Predis\Client();

if(redis-＞hexists('email.to.id', email)){

echo '该邮箱已经被注册过了。';

exit;

}

验证通过后接下来就需要将用户资料存入Redis中。在存储的时候要记住使用散列函数处理用户提交的密码，避免在数据库中存储明文密码。原因是如果数据库中数据泄露（外部原因或内部原因都有可能），攻击者也无法获得用户的真实密码，也便无法正常地登录进系统。更重要的是考虑到用户很可能在其他网站中也使用了同样的密码，所以明文密码泄露还会给用户造成额外的损失。

除此之外，还要避免使用速度较快的散列函数处理密码以防止攻击者使用穷举法破解密码，并且需要为每个用户生成一个随机的“盐”（salt）以避免攻击者使用彩虹表破解。这里作为示例，我们使用Bcrypt算法来对密码进行散列。PHP 5.3中提供的crypt函数支持Bcrypt算法，我们可以实现一个函数来随机生成盐并调用crypt函数获得散列后的密码：

function bcryptHash(rawPassword, round=8)

{

if(round ＜ 4 || round ＞ 31) round=8;

salt='2a' . str_pad(round, 2, '0', STR_PAD_LEFT) . '';

randomValue=openssl_random_pseudo_bytes(16);

salt .=substr(strtr(base64_encode(randomValue), '+', '.'), 0, 22);

return crypt(rawPassword, salt);

}

提示  openssl_random_pseudo_bytes函数需要安装OpenSSL扩展。

之后使用如下代码获得散列后的密码：

hashedPassword=bcryptHash(rawPassword);

存储用户资料就很简单了，所有命令都在第3章介绍过了。代码如下：

require './predis/autoload.php';

redis=new Predis\Client();

//首先获取一个自增的用户ID

userID=redis-＞incr('users:count');

//存储用户信息

redis-＞hmset("user:{userID}", array(

'email'=＞email,

'password'=＞hashedPassword,

'nickname'=＞nickname

));

//记得记录下邮箱和用户ID的对应关系

redis-＞hset('email.to.id', email, userID);

//提示用户注册成功

echo '注册成功！';

大部分情况下在注册时我们需要验证用户的邮箱，不过这部分的逻辑与忘记密码部分相似，所以在这里不做更多的介绍。

2．登录

需求描述：用户登录时需要提交邮箱和登录密码，如果正确则输出“登录成功”，否则输出“用户名或密码错误”。

当用户提交邮箱和登录密码后首先通过email.to.id键获得用户ID，然后将用户提交的登录密码使用同样的盐进行散列并与数据库存储的密码比对，如果一样则表示登录成功。我们新建一个login.php文件来处理用户的登录，处理该逻辑的部分代码如下：

header("Content-type: text/html; charset=utf-8");

if(!isset(_POST['email']) ||

!isset(_POST['password'])){

echo '请填写完整的信息。';

exit;

}

email=_POST['email'];

rawPassword=_POST['password'];

require './predis/autoload.php';

redis=new Predis\Client();

//获得用户的ID

userID=redis-＞hget('email.to.id', email);

if(!userID){

echo '用户名或密码错误。';

exit;

}

hashedPassword = redis-＞hget("user:{userID}", 'password');

现在我们得到了之前存储过的经过散列后的密码，接着定义一个函数来对用户提交的密码进行散列处理。bcryptHash函数中返回的密码中已经包含了盐，所以只需要直接将散列后的密码作为crypt 函数的第二个参数，crypt函数会自动地提取出密码中的盐：

function bcryptVerify(rawPassword, storedHash)

{

return crypt(rawPassword, storedHash) == storedHash;

}

之后就可以使用此函数进行比对了：

if(!bcryptVerify(rawPassword, hashedPassword)){

echo'用户名或密码错误。';

exit;

}

echo'登录成功！';

3．忘记密码

需求描述：当用户忘记密码时可以输入自己的邮箱，系统会发送一封包含更改密码的链接的邮件，用户单击该链接后会进入密码修改页面。该模块的访问频率限制为1分钟1O次以防止恶意用户通过此模块向某个邮箱地址大量发送垃圾邮件。

当用户在忘记密码的页面输入邮箱后，我们的程序需要做两件事。

（1）进行访问频率限制。这里使用4.2.3节介绍的方法以邮箱为标示符对发送修改密码邮件的过程进行访问频率限制。当用户提交了邮箱地址后首先验证邮箱地址是否正确，如果正确则检查访问频率是否超限：

keyName="rate.limiting:{email}";

now=time();

if(redis-＞llen(keyName)＜10){

redis-＞lpush(keyName, now);

}else{

time = redis-＞lindex(keyName, -1);

if(now - time＜60){

echo '访问频率超过了限制，请稍后再试。';

exit;

}else{

redis-＞lpush(keyName, now);

redis-＞ltrim(keyName, 0, 9);

}

}

一般在全站中还会有针对IP地址的访问频率限制，原理与此类似。

（2）发送修改密码邮件。用户通过访问频率限制后我们会为其生成一个随机的验证码，并将验证码通过邮件发送给用户。同时在程序中要把用户的邮箱地址存入名为retrieve.password.code:散列后的验证码的字符串类型键中，然后使用EXPIRE命令为其设置一个生存时间（如1个小时）以提供安全性并且保证及时释放存储空间。由于忘记密码需要的安全等级与用户注册登录相同，所以我们依然使用Bcrypt算法来对验证码进行散列，具体的算法同上这里不再详述。

5.2 Ruby与Redis

Redis官方推荐的Ruby客户端是redis-rb① ，也是各种语言的Redis客户端中最为稳定的一个。其主要代码贡献者就是Redis的开发者之一Pieter Noordhuis。

注释：①见https://github.com/redis/redis-rb。

5.2.1 安装

使用gem install redis安装最新版本的redis-rb，目前的最新版本是3.0.2。

5.2.2 使用方法

创建到Redis的连接很简单：

require 'redis'

redis=Redis.new

该行代码会默认Redis的地址为127.0.0.1，端口为6379。如果需要更改地址或端口，可以使：

redis=Redis.new(:host=＞'127.0.0.1', :port =＞ 6379)

redis-rb的官方文档相对比较详细，所以具体的使用方法可以见其GitHub主页。这里从其中挑出几个比较有代表性的命令作为示例：

r.set('redis_db', 'great k / v storage') #=＞OK

r.get('redis_db') #=＞"great k / v storage"

r.incrby('counter', 99) #=＞99

r.hmset('hash_dt', :key2, 'value2', :key3, 'value3') #=＞OK

5.2.3 简便用法

redis-rb最便捷的命令调用方法就是对SET和GET命令使用别名[]，例如：

redis.set('key', 'value')

可以写成

redis['key']='value'

同样

value=redis.get('key')

可以写成

value=redis['key']

另外，对于事务的返回值可以提前设置对结果的引用，就像这样：

redis.multi do

redis.set('key', 'hi')

@value=redis.get('key')

redis.set('key', '2')

@number=redis.incr('key')

end

p @value.value #输出"hi"

p @number.value #输出3

5.2.4 实践：自动完成

现在很多网站都有标签功能，用户可以给某个项目（如文章、图书等）添加标签，也可以通过标签查询项目。在很多时候，我们都希望在用户输入标签时网站可以自动帮助用户补全要输入的标签，如图5-1所示。

图5-1 输入“tech”后网站会列出以“tech”开头的标签

这样做一是可以节约用户的输入时间，二是在创建标签时可以起到规范标签的作用，避免用户输入标签时可能出现的拼写错误。

下面介绍两种在Redis中实现补全提示的方法，并会挑选一种用Ruby来实现。

第一种方法：为每个标签的每个前缀都使用一个集合类型键来存储该前缀对应的标签名。如“ruby”的所有前缀分别是“r”、“ru”和“rub”，我们为这3个前缀对应的集合类型键都加入元素“ruby”。

当有“ruby”和“redis”两个标签时Redis中存储的内容如图5-2所示，用户输入“r”时就可以通过读取键“prefix:r”来获知以“r”开头的标签有“ruby”和“redis”两个。

图5-2 “ruby”和“redis”两个标签的索引存储结构

这时就可以将这两个标签提示给用户了。更进一步，我们还可以存储每个标签的访问量，使得我们可以利用SORT命令配合BY参数把最热门的标签排在前面。

第二种方法通过有序集合实现，该方法是由Redis的作者Salvatore Sanfilippo介绍的。

3.6节介绍过有序集合类型有一个特性是当元素的分数一样时会按照元素值的字典顺序排序，利用这一特性只使用一个有序集合类型键就能实现标签的补全功能，准备过程如下。

（1）首先把每个标签名的所有前缀作为元素存入键中，分数均为0；

（2）将每个标签名后面都加上“*”符号并存入键中，分数也为0。准备过后的存储情况如图5-3所示。

图5-3 “ruby”和“redis”两个标签的索引存储结构

由于所有元素的分数都相同，所以该有序集合键中的项目相当于全部按照字典顺序排序（即图5-3所示的顺序）。这样当用户输入“r”时就可以按照如下流程获取要提示给用户的标签：

获取“r”的排名：ZRANK autocomplete r，在这里的返回值是0；

获取“r”之后的N个元素，如当N=100时：ZRANGE autocomplete 1101。 N的取值与标签的平均长度和需要获得的标签数量有关，可以根据实际情况自由调整；

遍历返回的结果，找出其中以"*"结尾的且以“r”开头的元素。此时将“*”去掉后就是我们需要的结果了。

下面我们写一个小程序来作为示例，程序启动时会从一个文本文件中读取所有标签列表，然后接收用户输入并返回相应的补全结果。

文本文件的样例内容如下：

我的中国心

我的中国话

你好吗

我和你

你一路走来

你从哪里来

当用户输入“我的”时程序会打印如下内容：

我的中国心

我的中国话

具体的实现方法是，首先我们定义一个函数来获得标签的前缀（包括标签加上星号）：

#获得标签的所有前缀

#

# @example

# get_prefixes('word')

# #=＞['w', 'wo', 'wor', 'word＊']

def get_prefixes(word)

Array.new(word.length) do |i|

if i == word.length - 1

"#{word}＊"

else

word[0..i]

end

end

end

接着我们加载redis-rb，并建立到Redis的连接：

require 'redis'

#建立到默认地址和端口的Redis 的连接

redis=Redis.new

为了保证可以重复运行此程序，我们需要删除之前建立的键以免影响本次的结果：

redis.del('autocomplete')

下面是准备阶段，程序从words.txt文件读取标签列表，并获得每个标签的前缀加入到有序集合键中：

argv=[]

File.open('words.txt').each_line do |word|

get_prefixes(word.chomp).each do |prefix|

argv ＜＜ [0, prefix]

end

end

redis.zadd('autocomplete', argv)

redis-rb的zadd函数支持两种方式的参数：当只加入一个元素时使用redis.zadd(key, score, member)，当同时加入多个元素时使用redis.zadd(key, [[score1,member1], [score2, member2], …])上面的代码使用的是后一种方式。

最后一步我们通过循环来接收用户的输入并查询对应的标签：

while prefix=gets.chomp do

result=[]

if(rank = redis.zrank('autocomplete', prefix))

#存在以用户输入的内容为前缀的标签

redis.zrange('autocomplete', rank + 1, rank + 100).each do |words|

#获得该前缀后的100 个元素

if words[-1] == '＊' ＆＆ prefix == words[0..prefix.length - 1]

#如果以"＊"结尾并以用户输入的内容为前缀则加入结果中

result ＜＜ words[0..-2]

end

end

end

#打印结果

puts result

end

5.3 Python与Redis

Redis官方推荐的Python客户端是redis-py① 。

注释：①见https://github.com/andymccurdy/redis-py。

5.3.1 安装

推荐使用pip install redis安装最新版本的redis-py，也可以使用easy_install：easy_ install redis。

5.3.2 使用方法

首先需要引入redis-py：

import redis

下面的代码将创建一个默认连接到地址127.0.0.1，端口6379的Redis连接：

r=redis.StrictRedis()

也可以显式地指定需要连接的地址：

r=redis.StrictRedis(host='127.0.0.1', port=6379, db=0)

使用起来很容易，这里以SET和GET命令作为示例：

r.set('foo', 'bar') # True

r.get('foo') # 'bar'

5.3.3 简便用法

1．HMSET/HGETALL

HMSET支持将字典作为参数存储，同时HGETALL的返回值也是一个字典，搭配使用十分方便：

r.hmset('dict', {'name': 'Bob'})

people=r.hgetall('dict')

print people # {'name': 'Bob'}

2．事务和管道

redis-py的事务使用方式如下：

pipe=r.pipeline()

pipe.set('foo', 'bar')

pipe.get('foo')

result=pipe.execute()

print result # [True, 'bar']

管道的使用方式和事务相同，只不过需要在创建时加上参数transaction=False：

pipe=r.pipeline(transaction=False)

事务和管道还支持链式调用：

result=r.pipeline().set('foo', 'bar').get('foo').execute()

# [True, 'bar']

5.3.4 实践：在线的好友

一般的社交网站上都可以看到用户在线的好友列表，如图5-4所示。在Redis中可以很容易地实现这个功能。

图5-4 某网站上用户的在线好友列表

在线好友其实就是全站在线用户的集合和某个用户所有好友的集合取交集的结果。如果现在我们的网站就是使用集合类型键来存储用户的好友ID的，那么只需要一个存储在线用户列表的集合即可。如何判定一个用户是否在线呢？通常的方法是每当用户发送HTTP请求时都记录下请求发生的时间，所有指定时间内发送过请求的用户就算作在线用户。这段时间根据场景不同取值也不同，以10分钟为例：某个用户发送了一个HTTP请求，9分钟后系统仍然认为他是在线的，但到了第11分钟就不算作他在线了。

在Redis中我们可以每隔10分钟就使用一个键来存储该10分钟内发送过请求的用户ID列表。如12点20分到12点29分的用户ID存储在active.users:2 中，12点30分到12点39分的用户ID存储在active.users:3中，以此类推（注意每次调用SADD命令增加用户ID时需要同时设置键的生存时间在50分钟内以防止命名沖突）。这样需要获得当前在线用户只需要读取当前分钟数对应的键即可。不过这种方案会造成较大的误差，比如某个用户在29分访问了一个页面，他的ID被记录在active.users:2键中，而在30分时系统会读取active.users:3键来获取在线用户列表，即该用户的在线状态只持续了1分钟而不是预想的10分钟。

这时就需要粒度更小的记录方案来解决这个问题。我们可以将原先每10分钟记录一个键改为每1分钟记录一个键，即在12点29分访问的用户的ID将会被记录在active.users:29中。而判断一个用户是否在最近10分钟在线只需要判断其在最近的10个集合键中是否出现过至少一次即可，这一过程可以通过SUNION命令实现。

下面介绍使用Python来实现这一过程。我们这里使用了web.py框架，web.py是一个易于使用的Python网站开发框架，可以通过sudo pip install web.py来安装它。

代码如下：

#-＊- coding: utf-8 -＊-

import web

import time

import redis

r=redis.StrictRedis()"

"" 配置路由规则

'/': 模拟用户的访问

'/online':查看在线用户

"""

urls=(

'/', 'visit',

'/online', 'online'

)

app=web.application(urls, globals())

"""返回当前时间对应的键名

如28分对应的键名是active.users:28

"""

def time_to_key(current_time):

return 'active.users:' + time.strftime('%M', time.localtime(current_time))

""" 返回最近10分钟的键名

结果是列表类型

"""

def keys_in_last_10_minutes():

now=time.time()

result=[]

for i in range(10):

result.append(time_to_key(now - i ＊ 60))

return result

class visit:

""" 模拟用户访问

将用户的User agent作为用户的ID加入到当前时间对应的键中

"""

def GET(self):

user_id=web.ctx.env['HTTP_USER_AGENT']

current_key=time_to_key(time.time())

pipe = r.pipeline()

pipe.sadd(current_key, user_id)

#设置键的生存时间为10分钟

pipe.expire(current_key, 10 ＊ 60)

pipe.execute()

return 'User:\t' + user_id + '\r\nKey:\t' + current_key

class online:

""" 查看当前在线的用户列表

"""

def GET(self):

online_users=r.sunion(keys_in_last_10_minutes())

result=''

for user in online_users:

result += 'User agent:' + user + '\r\n'

return result

if __name__ == "__main__":

app.run()

在代码中我们建立了两个页面。首先我们打开http://127.0.0.1:8080，该页面对应visit类，每次访问该页面都会将用户的浏览器User agent存储在记录当前分钟在线用户的键中，并将User agent和键名显示出来，如图5-5所示。

图5-5 使用Safari访问http://127.0.0.1:8080

从键名可知该次访问是在某时26分钟的时候发生的。然后使用另一个浏览器打开该页面，如图5-6所示。

图5-6 使用Firefox访问http://127.0.0.1:8080

该次访问发生在29分钟。最后我们在37分钟时访问http://127.0.0.1:8080/online来查看当前在线用户列表，如图5-7所示。

图5-7 查看在线用户结果

结果与预期一样，在线列表中只有在29分钟访问的用户。

另一种方法：有序集合

有时网站本来就要记录全站用户的最后访问时间（如图5-8所示），这时就可以直接利用此数据获得最后一次访问发生在10分钟内的用户列表（即在线用户）。

图5-8 Stack Overflow 网站的个人资料页面记录了用户上次访问的时间

我们使用一个有序集合来记录用户的最后访问时间，元素值为用户的ID，分数为最后一次访问的UNIX时间。要获得最近10分钟访问过的用户列表可以使用ZRANGEBYSCORE命令：

ten_minutes_ago=time.time() - 10 ＊ 60

online_users=r.zrangebyscore('last.seen', ten_minutes_ago, '+inf')

那么如何获取在线的好友列表呢（与上一个例子一样，此时依然使用集合类型存储用户的好友列表）？最直接的方法就是将上面存储在线用户列表的online_users变量存入Redis的一个集合类型的键中然后和用户的好友列表取交集。然而这种方法需要在服务端和客户端之间传输数据，如果在线用户多的话会有较大的网络开销，而且这种方法也不能通过Redis的事务功能实现原子操作。为了解决这些问题，我们希望实现一个方法将ZRANGEBYSCORE命令的结果直接存入一个新键中而不返回到客户端。思路如下：有序集合只有ZINTERSTORE和ZUNIONSTORE两个命令支持直接将运算结果存入键中，然而这两个命令都不能实现我们要的操作。所以只能换种思路：既然没办法直接把有序集合中某一分数段的元素存入新键中，那何不干脆复制一个新建，并使用ZREMRANGEBYSCORE命令将我们不需要的分数段的元素删除？

有了这一思路后下面的实现方法就很简单了，步骤如下。

（1）复制一个last.seen键的副本temp.last.seen，方法为ZUNIONSTORE temp.last.seen 1 last.seen。在这里我们巧妙地借助了ZUNIONSTORE命令实现了对有序集合类型键的复制过程，即参加求并集操作的元素只有一个，结果自然就是它本身。

（2）将不在线的用户（即1O分钟以前的用户）删除。方法为ZREMRANGEBYSCOREtemp.last.seen 0 10分钟前的UNIX时间。

（3）现在temp.last.seen键中存储的就是当前的在线用户了。我们将其和用户的好友列表做交集：ZINTERSTORE online.friends 2 temp.last.seen user:42:friends。这里我们以ID为42的用户举例，user:42:friends是存储其好友的集合类型键① 。

注释：①ZINTERSTORE命令的参数除了有序集合类型外还可以是集合类型，此时的集合类型会被作为分数为1的有序集合类型处理。

（4）使用ZRANGE命令获取online.friends键的值。

（5）收尾工作，删除temp.last.seen和online.friends键。因为temp.last.seen键可以被所有用户共用，所以可以根据情况将其缓存一段时间，在下次需要生成时先判断是否有该键，如果有则直接使用。

以上5步需要使用事务或脚本实现以保证每个步骤的原子性。

有的时候我们会使用有序集合键来存储用户的好友列表以记录成为好友的时间，此时第3步依然奏效。

虽然以上的步骤有些复杂，但是实现起来并不难，有兴趣的读者可以自己完成。

5.4 Node.js与Redis

Redis官方推荐的Node.js客户端是node_redis① 。

注释：①见https://github.com/mranney/node_redis。

5.4.1 安装

使用npm install redis命令安装最新版本的node_redis，目前版本是0.8.2。

5.4.2 使用方法

首先加载node_redis模块：

var redis = require('redis');

下面的代码将创建一个默认连接到地址127.0.0.1，端口6379的Redis连接：

var client = redis.createClient();

也可以显式地指定需要连接的地址：

var client = redis.createClient('6379', '127.0.0.1')

由于Node.js的异步特性，在处理返回值的时候与其他客户端差别较大。还是以GET/SET命令为例：

client.set('foo', 'bar', function () {

//此时SET命令执行完并返回结果，

//因为这里并不关心SET命令的结果，所以我们省略了回调函数的形参。

client.get('foo', function (error, fooValue) {

//error 参数存储了命令执行时返回的错误信息，如果没有错误则返回null。

//回调函数的第二个参数存储的是命令执行的结果

console.log(fooValue); // 'bar'

});

});

使用node_redis执行命令时需要传入回调函数（callback function）来获得返回值，当命令执行完返回结果后node_redis会调用该函数，并将命令的错误信息作为第一个参数、返回值作为第二个参数传递给该函数。关于Node.js的异步模型的介绍超出了本书的范围，有兴趣的读者可以访问Node.js的官网① 了解更多信息。

注释：①见http://nodejs.org。

Node.js的异步模型使得通过node_redis调用Redis命令的表现与Redis的底层管道协议十分相似：调用命令函数时（如client.set()）并不会等待Redis返回命令执行结果，而是直接继续执行下一条语句，所以在Node.js中通过异步模型就能实现与管道类似的效果（也因此 node_redis没有提供管道相关的命令）。上面的例子中我们并不需要SET命令的返回值，只要保证SET命令在GET命令前发出即可，所以完全不用等待SET命令返回结果后再执行GET命令。因此上面的代码可以改写成：

//不需要返回值时可以省略回调函数

client.set('foo', 'bar');

client.get('foo', function (error, fooValue) {

console.log(fooValue); // 'bar'

});

不过由于SET和GET并未真正使用Redis的管道协议发送，所以当有多个客户端同时向Redis发送命令时，上例中的两个命令之间可能会被插入其他命令，换句话说，GET命令得到的值未必是“bar”。

虽然Node.js的异步特性给我们带来了相对更高的性能，然而另一方面使用Redis实现某个功能时我们经常需要读写若干个键，而且很多情况下都会依赖之前命令的返回结果。这时就会出现嵌套多重回调函数的情况，影响代码可读性。就像这样：

client.get('people:2:home', function (error, home){

client.hget('locations', home, function (error, address) {

client.exists('address:' + address, function (errror, addressExists) {

if (addressExists) {

console.log('地址存在。');

} else {

client.exists('backup.address:' + address, function (error,

backupAddress Exists) {

if (backupAddressExists) {

console.log('备用地址存在。');

} else {

console.log('地址不存在。');

}

});

}

});

})

});

上面的代码并不是极端的情况，相反在实际开发中经常会遇到这种多层嵌套。为了减少嵌套，可以考虑使用Async② 、Step③ 等第三方模块。如上面的代码可以稍微修改后使用Async重写为：

注释：②见https://github.com/caolan/async。

注释：③见https://github.com/creationix/step。

async.waterfall([

function (callback) {

client.get('people:2:home', callback);

},

function (home, callback) {

client.hget('locations', home, callback);

},

function (address, callback) {

async.parallel([

function (callback) {

client.exists('address:' + address, callback);

},

function (callback) {

client.exists('backup.address:' + address, callback);

},

], function (err, results) {

if (results[0]) {

console.log('地址存在。');

}else if (results[1]) {

console.log('备用地址存在。');

} else {

console.log('地址不存在。');

}

});

}

]);

5.4.3 简便用法

1．HMSET/HGETALL

node_redis同样支持在HMSET命令中使用对象作参数（对象的属性值只能是字符串），相应的HGETALL命令会返回一个对象。

2．事务

事务的用法如下：

var multi=client.multi();

multi.set('foo', 'bar');

multi.sadd('set', 'a');

mulit.exec(function (err, replies) {

//replies 是一个数组，依次存放事务队列中命令的结果

console.log(replies);

});

或者使用链式调用：

client.multi()

.set('foo', 'bar')

.sadd('set', 'a')

.exec(function (err, replies) {

console.log(replies);

});

3．“发布/订阅”模式

Node.js使用事件的方式实现“发布/订阅”模式。现在创建两个连接分别充当发布者和订阅者：

var pub=redis.createClient();

var sub=redis.createClient();

然后让sub订阅chat频道：

sub.subscribe('chat');

定义当接收到消息时要执行的回调函数：

sub.on('message', function (channel, message) {

console.log('收到' + channel + '频道的消息：' + message);

});

在sub订阅成功后，我们让pub向chat频道发送一个问候信息：

sub.on('subscribe', function (channel, count) {

pub.publish('chat', 'hi!');

})

运行后可以看到打印的结果：

node testpubsub.js

收到chat 频道的消息：'hi!'

补充知识  在node_redis中建立连接的过程同样是异步的，即执行client=redis. createClient()后并未立即建立连接。在连接建立完成前执行的命令会被加入到离线任务队列中，当连接建立成功后node_redis会按照加入的顺序依次执行离线任务队列中的命令。

5.4.4 实践：IP地址查询

很多场合下网站都需要根据访客的IP地址判断访客所在地。假设我们有一个地名和IP地址段的对应表① ：

注释：①该表只用于演示用途，其中的数据并不准确。

上海: 202.127.0.0～202.127.4.255

北京: 122.200.64.0～122.207.255.255

如果用户的IP地址为122.202.2.0，我们就能根据这个表知道他的地址位于北京。Redis可以使用一个有序集合类型的键来存储这个表。

首先将表中的IP 地址转换成十进制数字：

上海: 3397320704～3397321983

北京: 2059943936～2060451839

然后使用有序集合类型记录这个表。方式为每个地点存储两条数据：一条的元素值是地点名，分数是该地点对应的最大IP地址。另一条是“*”加上地点名，分数是该地点对应的最小IP地址，如图5-9所示。

图5-9 使用有序集合键存储地点和相应IP范围的存储结构

在查找某个IP地址属于哪个地点时先将该IP地址转换成十进制数字，然后在有序集合中找到大于该数字的最小的一个元素，如果该元素不是以“*”开头则表示找到了，如果是则表示数据库中并未记录该IP地址对应的地名。

如我们想找到“122.202.2.0”的所在地，首先将其转换成数字“2060059136”，然后在有序集合中找到第一个大于它的分数为“2060451839”，对应的元素值为“北京”，不是以“*”开头，所以该地址的所在地是北京。

下面介绍使用Node.js实现这一过程。首先将表转换成CSV格式并存为ip.csv：

上海,202.127.0.0,202.127.4.255

北京,122.200.64.0,122.207.255.255

而后使用node-csv-parser模块① 加载该csv文件：

注释：①见https://github.com/wdavidw/node-csv-parser。安装方法为npm install csv。

var fs=require('fs');

var csv=require('csv');

csv().from.stream(fs.createReadStream('ip.csv'))

.on('record', importIP);

读取每行数据时node-csv-parser模块都会调用importIP回调函数。该函数实现如下：

var redis=require('redis');

var client=redis.createClient();

//将IP地址数据加入Redis

//输入格式："['上海', '202.127.0.0', '202.127.4.255']"

function importIP (data) {

var location = data[0];var minIP=convertIPtoNumber(data[1]);

var maxIP=convertIPtoNumber(data[2]);

//将数据加入到有序集合中，键名为'ip'

client.zadd('ip', minIP, '＊' + location, maxIP, location);

}

其中convertIPtoNumber函数用来将IP地址转换成十进制数字，

//将IP地址转换成十进制数字

//convertIPtoNumber('127.0.0.1') =＞ 2130706433

function convertIPtoNumber(ip) {

var result='';

ip.split('.').forEach(function (item) {

item=～～item;

item=item.toString(2);

item=pad(item, 8);

result += item;

});

return parseInt(result, 2);

}

pad函数用于将二进制数补全为8位：

//在字符串前补'0'。

//pad('11', 3)=＞'011'

function pad(num, n) {

var len=num.length;

while(len＜n) {

num='0'+num;

len++;

}

return num;

}

至此数据准备工作完成了，现在我们提供一个接口来供用户查询：

var readline=require('readline');

var rl=readline.createInterface({

input: process.stdin,

output: process.stdout

});

rl.setPrompt('IP＞ ');

rl.prompt();

rl.on('line', function (line) {

ip=convertIPtoNumber(line);

client.zrangebyscore('ip', ip, '+inf', 'LIMIT', '0', '1', function (err,result) {

if (!Array.isArray(result) || result.length === 0) {

//该IP地址超出了数据库记录的最大IP地址

console.log('No data.');

} else {

var location=result[0];

if (location[0]==='＊') {

//该IP地址不属于任何一个IP地址段

console.log('No data.');

} else {

console.log(location);

}

}

rl.prompt();

});

});

运行后的结果如下：

node ip_search.js

IP＞127.0.0.1

No data.

IP＞122.202.23.34

北京

IP＞202.127.3.3

上海

上面的代码的实际查找范围是一个半开半闭区间。如果想实现闭区间查找，读者可以在比对“＊”时同时比较元素的分数和查找的IP地址是否相同。

第6章 脚本

小白花了5天时间看完了宋老师发在学校网站上的4个编程语言的Redis客户端教程，感觉收获颇丰，但还有一件事一直挂在心上：宋老师提到过很多次Redis的脚本功能，但到现在还没具体讲解过。一天中午他来到了宋老师的办公室想向其请教脚本的知识，看到宋老师正在睡觉，便想先出去转转等会儿再来问。正回身要走突然瞥到了宋老师的电脑屏幕，上面打开着一篇文档，而文档的标题正是“Redis脚本功能介绍”。

过了几天小白就收到了发自宋老师的邮件—“Redis脚本功能介绍”。

6.1 概览

4.2.2节实现了访问频率限制功能，可以限制一个IP地址在1分钟内最多只能访问100次：

isKeyExists=EXISTS rate.limiting:IP

if isKeyExists is 1

times=INCR rate.limiting:IP

if times ＞ 100

print访问频率超过了限制，请稍后再试。

exit

else

MULTI

INCR rate.limiting:IP

EXPIRE keyName, 60

EXEC

当时提到上面的代码会出现竞态条件，解决方法是用WATCH命令检测rate.limiting:IP键的变动，但是这样做比较麻烦，而且还需要判断事务是否因为键被改动而没有执行。除此之外这段代码在不使用管道的情况下最多要向Redis请求5条命令，在网络传输上会浪费很多时间。

我们这时最希望的就是Redis直接提供一个“RATELIM ITING”命令用来实现访问频率限制功能，这个命令只需要我们提供键名、时间限制和在时间限制内最多访问的次数三个参数就可以直接返回访问频率是否超限。就像这样。

if RATELIMITING rate.limiting:IP, 60, 100

print访问频率超过了限制，请稍后再试。

else

# 没有超限，显示博客内容

这种方式不仅代码简单、没有竞态条件（Redis的命令都是原子的），而且减少了通过网络发送和接收命令的传输开销。然而可惜的是Redis并没有提供这个命令，不过我们可以使用Redis脚本功能自己定义新的命令。

6.1.1 脚本介绍

Redis在2.6版推出了脚本功能，允许开发者使用Lua语言编写脚本传到Redis中执行。在Lua脚本中可以调用大部分的Redis命令，也就是说可以将6.1节中的第一段代码改写成Lua脚本后发送给Redis执行。使用脚本的好处如下。

（1）减少网络开销：6.1节中的第一段代码最多需要向Redis发送5次请求，而使用脚本功能完成同样的操作只需要发送一个请求即可，减少了网络往返时延。

（2）原子操作：Redis会将整个脚本作为一个整体执行，中间不会被其他命令插入。换句话说在编写脚本的过程中无需担心会出现竞态条件，也就无需使用事务。事务可以完成的所有功能都可以用脚本来实现。

（3）复用：客户端发送的脚本会永久存储在Redis中，这就意味着其他客户端（可以是其他语言开发的项目）可以复用这一脚本而不需要使用代码完成同样的逻辑。

6.1.2 实例：访问频率限制

因为无需考虑事务，使用Redis脚本实现访问频率限制非常简单。Lua代码如下：

local times=redis.call('incr', KEYS[1])

if times==1 then

-- KEYS[1]键刚创建，所以为其设置生存时间

redis.call('expire', KEYS[1], ARGV[1])

end

if times＞tonumber(ARGV[2]) then

return 0

end

return 1

这段代码实现的功能与我们之前所做的类似，不过简洁了很多，即使不了解Lua语言也能猜出大概的意思。如果有的地方看不懂也没关系，6.2节会专门介绍Lua的语法和调用Redis命令的方法。

那么，如何测试这个脚本呢？首先把这段代码存为ratelimiting.lua，然后在命名行中输入：

redis-cli --eval /path/to/ratelimiting.lua rate.limiting:127.0.0.1 , 10 3

其中--eval 参数是告诉redis-cli读取并运行后面的Lua脚本，/path/to/ratelimiting.lua是ratelimiting.lua文件的位置，后面跟着的是传给Lua脚本的参数。其中“,”前的rate.limiting:127.0.0.1是要操作的键，可以在脚本中使用KEYS[1]获取，“,”后面的10和3是参数，在脚本中能够使用ARGV[1]和ARGV[2]获得。结合脚本的内容可知这行命令的作用就是将访问频率限制为每10秒最多3次，所以在终端中不断地运行此命令会发现当访问频率在10秒内小于或等于3次时返回1，否则返回0。

注意  上面的命令中“,”两边的空格不能省略，否则会出错。

对于KEYS和ARGV两个变量会在6.3节中详细介绍，在下一节中我们会专门介绍Lua的语法。

6.2 Lua语言

Lua① 是一个高效的轻量级脚本语言。Lua在葡萄牙语中是“月亮”的意思，它的徽标形似卫星（见图6-1），寓意着Lua是一个“卫星语言”，能够方便地嵌入到其他语言中使用。

注释：①见http://www.lua.org。

图6-1 Lua的徽标

为什么要在其他语言中嵌入Lua脚本呢？举一个例子，假设你要开发一个运行在iPhone上的电子宠物游戏，你可能希望设定玩家每次给宠物喂食，宠物的饥饿值就会减N点。如果N是一个定值，那么就可以将N硬编码到代码中。一切都很好，直到某天你发现有大量的玩家抱怨说自己的宠物简直太能吃了，每天需要喂几十次才能喂饱。这时你不得不发布一个新版本来提高N的值，并让玩家到App Store中升级整个游戏（这期间还有漫长的应用审核过程）。不过这次你有经验了：你将N的值存到了网上，每次游戏启动后都联网查询最新的N值。这样如果下次发现N不合适，只需要在网上修改一次，所有的玩家就能自动更新了。又平安无事地过了几天，你却发现即使可以随时调整N的值，但还是无法让玩家满意，诸如“为什么我的宠物一分钟内可以吃完一周的饭量？”这样的抱怨越来越多。你知道这次必须修改代码来限制短时间内不能连续喂食多次了，同样你又要经历从发布到审核的等待，而所有的玩家又得到App Store中为了这一段代码重新更新整个游戏。好在你终于意识到应该使用一个更好的方法——嵌入Lua脚本来实现这一更改了。现在你将喂食的逻辑写在Lua脚本中，例如：

function feed(timeSinceLastFeed)

local hungerValue=0

if timeSinceLastFeed ＞ 3600

hungerValue=((timeSinceLastFeed - 3600) / timeSinceLastFeed) ＊ 200

return hungerValue

end

然后在你的程序中嵌入一个Lua解释器，每次需要喂食时就通过解释器调用这个Lua脚本，并将上次喂食距现在的时间传给feed 函数，feed函数根据这个时间计算此次喂食需要减少的饥饿值：时间越短减少的饥饿值就越少。下次需要调整这个算法时只要从网上更新这个脚本就可以了，连游戏都不用重启。另外你还可以把宠物的状态如心情之类的传入这个函数，即使现在用不到，以后说不定也会用到。总之越多的逻辑放在脚本上，你的程序升级或扩展就越容易。

实际上很多iOS游戏中都使用了Lua语言，例如2011年很火的游戏《愤怒的小鸟》就是使用Lua语言实现的关卡，而就在那一年Lua在TIOBE世界编程语言排行榜上进入了前10名。另外风靡全球的网络游戏《魔兽世界》的插件也是使用Lua语言开发的。

其实Redis和电子宠物游戏遇到的问题有点相似，很多人都希望在Redis中加入各种各样的命令，这些命令中有的确实很实用，但却可以使用多个Redis已有的命令实现。在Redis中包含所有开发者需要的命令显然是不可能的，所以Redis在2.6版中提供了Lua脚本功能来让开发者自己扩展Redis。

6.2.1 Lua语法

Redis使用Lua 5.1版本，所以本书介绍的Lua语法基于此版本。本节不会完整地介绍Lua语言中的所有要素，而是只着重介绍编写Redis脚本会用到的部分，对Lua语言感兴趣的读者推荐阅读Lua作者Roberto Ierusalimschy① 写的Programming in Lua这本书。

注释：①见http://www.inf.puc-rio.br/~roberto。

1．数据类型

Lua是一个动态类型语言，一个变量可以存储任何类型的值。编写Redis脚本时会用到的类型如表6-1所示。

表6-1  Lua常用数据类型

2．变量

Lua的变量分为全局变量和局部变量。全局变量无需声明就可以直接使用，默认值是nil。如：

a=1 --为全局变量a赋值

print(b) --无需声明即可使用，默认值是nil

a=nil --删除全局变量a的方法是将其赋值为nil。全局变量没有声明和未声明之分，只有非nil和nil的区别

在Redis脚本中不能使用全局变量，只允许使用局部变量以防止脚本之间相互影响。声明局部变量的方法为local变量名，就像这样：

local c --声明一个局部变量c，默认值是nil

local d=1 --声明一个局部变量d并赋值为1

local e, f -- 可以同时声明多个局部变量

同样声明一个存储函数的局部变量的方法为：

local say_hi=function ()

print 'hi'

end

变量名必须是非数字开头，只能包含字母、数字和下划线，区分大小写。变量名不能与Lua的保留关键字相同，保留关键字如下：

and break do else elseif

end false for function if

in local nil not or

repeat return then true until while

局部变量的作用域为从声明开始到所在层的语句块末尾，比如：

local x=10

if true then

local x=x + 1

print(x)

do

local x=x + 1

print(x)

end

print(x)

end

print(x)

打印结果为：

11

12

11

10

3．注释

Lua的注释有单行和多行两种。

单行注释以--开始，到行尾结束，在上面的代码已经使用过了，一般习惯在--后面跟上一个空格。

多行注释以--[[开始，到]]结束，如：

--[[

这是一个多行注释

]]

4．赋值

Lua支持多重赋值，比如：

local a, b=1, 2 --a的值是1，b的值是2

local c, d=1, 2, 3 --c的值是1，d的值是2，3被舍弃了

local e, f=1 --e的值是1，f的值是nil

在执行多重赋值时，Lua会先计算所有表达式的值，比如

local a={1, 2, 3}

local i=1

i, a[i]=i+1, 5

Lua计算所有表达式的值后，上面最后一个赋值语句变为i, a[1]=2, 5，所以赋值后i的值为2，a则为{5, 2, 3}① 。

注释：①Lua的表类型索引是从1开始的，后文会介绍。

Lua中函数也可以返回多个值，后面会讲到。

5．操作符

Lua有以下5类操作符。

（1）数学操作符。数学操作符包括常见的+、-、＊、/、%（取模）、-（一元操作符，取负）和幂运算符号^。

数学操作符的操作数如果是字符串会自动转换成数字，比如：

print('1' + 1) --2

print('10' ＊ 2) --20

（2）比较操作符。Lua的比较操作符如表6-2所示。

表6-2  Lua的比较操作符

比较操作符的结果一定是布尔类型。比较操作符不同于数学操作符，不会对两边的操作数进行自动类型转换，也就是说：

print(1 == '1') --false,二者类型不同，不会进行自动类型转换

print({'a'} == {'a'}) --false,对于表类型值比较的是二者的引用

如果需要比较字符串和数字，可以手动进行类型转换。比如下面两个结果都是true：

print(1 == tonumber('1'))

print('1' == tostring(1))

其中tonumber函数还可以进行进制转换，比如：

print(tonumber('F', 16)) --将字符串'F'从十六进制转成十进制结果是15

（3）逻辑操作符。Lua的逻辑操作符如表6-3所示。

表6-3  Lua的逻辑操作符

只要操作数不是nil或false，逻辑操作符就认为操作数是真，否则是假。特别需要注意的是即使是0或空字符串也被当作真Ruby开发者肯定会比较适应这一点）。下面是几个逻辑操作符的例子：

print(1 and 5) --5

print(1 or 5) --1

print(not 0) --false

print('' or 1) -- ''

Lua的逻辑操作符支持短路，也就是说对于false and foo()，Lua不会调用foo函数，因为第一个操作数已经决定了无论foo函数返回的结果是什么，该表达式的值都是false。or 操作符与之类似。

（4）连接操作符。连接操作符只有一个：..，用来连接两个字符串，比如：

print('hello' .. ' ' .. 'world!') --'hello world!'

连接操作符会自动把数字类型的值转换成字符串类型：

print('The price is ' .. 25) --'The price is 25'

（5）取长度操作符。取长度操作符是Lua 5.1中新增加的操作符，同样只有一个，即#，用来获取字符串或表的长度：

print(#'hello') --5

各个运算符的优先级顺序如表6-4所示。

表6-4  运算符的优先级（优先级依次降低）

6．if语句

Lua的if语句格式如下：

if条件表达式then

语句块

elseif条件表达式then

语句块

else

语句块

end

注意  前面提到过在Lua中只有nil和false才是假，其余值，包括空字符串和0，都被认为是真值。这是一个容易出问题的地方，比如Redis的EXISTS命令返回值1和0分别表示存在或不存在，但下面的代码无论EXISTS命令的结果是1还是0，exists变量的值都是true：

if redis.call('exists', 'key') then

exists=true

else

exists=false

end

所以需要将redis.call('exists', 'key')改写成redis.call('exists','key') == 1 才正确。

Lua与JavaScript一样每个语句都可以以;结尾，但一般来说编写Lua时都会省略;（Lua的作者也是这样做的）。Lua也并不强制要求缩进，所有语句也可以写在一行中，比如：

a=1

b=2

if a then

b=3

else

b=4

end

可以写成

a=1 b=2 if a then b=3 else b=4 end

甚至如下代码也是正确的：

a=

1 b=2 if a

then b=3 else b

=4 end

但为了增强可读性，在编写的时候一定要注意缩进。

7．循环语句

Lua支持while, repeat和for循环语句。

while语句的形式为：

while条件表达式do

语句块

end

repeat语句的形式为：

repeat

语句块

until条件表达式

for语句有两种形式，一种是数字形式：

for变量=初值, 终值, 步长do

语句块

end

其中步长可以省略，默认步长为1。比如使用for循环计算1～100的和：

local sum=0

for i=1, 100 do

sum=sum+i

end

提示  for语句中的循环变量（即本例中的i）是局部变量，作用域为for循环体內。虽然没有使用local声明，但它不是全局变量。

for语句的通用形式为：

for变量1,变量2,...,变量N in迭代器do

语句块

end

在编写Redis脚本时我们常用通用形式的for语句遍历表的值，下面还会再介绍。

8．表类型

表是Lua中唯一的数据结构，可以理解为关联数组，任何类型的值（除了空类型）都可以作为表的索引。

表的定义方式为：

a={} --将变量a赋值为一个空表

a['field']='value' --将field字段赋值value

print(a.field) --打印内容为'value'，a.field是a['field']的语法糖。

people={ --也可以这样定义

name='Bob',

age=29

}

print(people.name) --打印的内容为'Bob'

当索引为整数的时候表和传统的数组一样，例如：

a={}

a[1]='Bob'

a[2]='Jeff'

可以写成下面这样：

a={'Bob', 'Jeff'}

print(a[1]) --打印的内容为'Bob'

注意  Lua约定数组① 的索引是从1开始的，而不是0。

注释：①此处的数组指的是数组形式的表类型，即索引为从1开始的递增整数。

可以使用通用形式的for 语句遍历数组，例如：

for index, value in ipairs(a) do

print(index) --index迭代数组a的索引

print(value) --value迭代数组a的值

end

打印的结果是：

1

Bob

2

Jeff

ipairs是Lua内置的函数，实现类似迭代器的功能。当然还可以使用数字形式的for语句遍历数组，例如：

for i=1, #a do

print(i)

print(a[i])

end

输出的结果和上例相同。#a的作用是获取表a的长度。Lua还提供了一个迭代器pairs，用来遍历非数组的表值，例如：

people={

name='Bob',

age=29

}

for index, value in pairs(people) do

print(index)

print(value)

end

打印结果为：

name

Bob

age

29

pairs与ipairs的区别在于前者会遍历所有值不为nil的索引，而后者只会从索引1开始递增遍历到最后一个值不为nil的整数索引。

9．函数

函数的定义为：

function(参数列表)

函数体

end

可以将其赋值给一个局部变量，比如：

local square=function(num)

return num ＊ num

end

如果没有参数，括号也不能省略。Lua还提供了一个语法糖来简化函数的定义，比如：

local function square (num)

return num ＊ num

end

这段代码会被转换为：

local square

square=function(num)

return num ＊ num

end

因为在赋值前声明了局部变量square，所以可以在函数内部引用自身（实现递归）。

如果实参的个数小于形参的个数，则没有匹配到的形参的值为nil。相对应的，如果实参的个数大于形参的个数，则多出的实参会被忽略。如果希望捕获多出的实参（即实现可变参数个数），可以让最后一个形参为...。比如，希望传入若干个参数计算这些数的平方：

local function square (...)

local argv = {...}

for i=1, #argv do

argv[i]=argv[i] ＊ argv[i]

end

return unpack(argv)

end

a, b, c=square(1, 2, 3)

print(a)

print(b)

print(c)

输出结果为：

1

4

9

在第二个square函数中，我们首先将...转换为表argv，然后对表的每个元素计算其平方值。unpack函数用来返回表中的元素，在上例中argv表中有3个元素，所以return unpack(argv)相当于return argv[1], argv[2], argv[3]。

在Lua中return和break（用于跳出循环）语句必须是语句块中的最后一条语句，简单地说在这两条语句后面只能是end，else或until三者之一。如果希望在语句块的中间使用这两条语句的话可以人为地使用do和end将其包围。

6.2.2 标准库

Lua的标准库中提供了很多实用的函数，比如前面介绍的迭代器ipairs和pairs，类型转换函数tonumber和tostring，还有unpack函数都属于标准库中的“Base”库。

Redis支持大部分Lua标准库，如表6-5所示。

表6-5  Redis支持的Lua标准库

下面会简单介绍几个常用的标准库函数，要了解全部函数请查看Lua手册① 。

注释：①见http://www.lua.org/manual/5.1/manual.html#5。

1．String库

String库的函数可以通过字符串类型的变量以面向对象的形式访问，如string.len(string_var)可以写成string_var:len()。

（1）获取字符串长度。

string.len(string)

string.len()的作用和操作符#类似，例如：

＞print(string.len('hello'))

5

＞print(#'hello')

5

（2）转换大小写。

string.lower(string)

string.upper(string)

例如：

＞print(string.lower('HELLO'))

hello

＞print(string.upper('hello'))

HELLO

（3）获取子字符串。

string.sub(string, start [, end])

string.sub()可以获取一个字符串从索引start开始到end结束的子字符串，索引从1开始。索引可以是负数，-1代表最后一个元素。end参数如果省略则默认是-1即截取到字符串末尾）。

例如：

＞print(string.sub('hello', 1))

hello

＞print(string.sub('hello', 2))

ello

＞print(string.sub('hello', 2, -2))

ell

＞print(string.sub('hello', -2))

lo

2．Table库

Table库中的大部分函数都需要表的形式是数组形式。

（1）将数组转换为字符串。

table.concat(table [, sep [, i [, j]]])

table.concat()与JavaScript中的join()类似，可以将一个数组转换成字符串，中间以sep参数指定的字符串分割，默认为空。i和j用来限制要转换的表元素的索引范围，默认分别是1和表的长度，不支持负索引。例如：

＞print(table.concat({1, 2, 3}))

123

＞print(table.concat({1, 2, 3}, ',', 2))

2,3

＞print(table.concat({1, 2, 3}, ',', 2, 2))

2

（2）向数组中插入元素

table.insert(table, [pos,] value)

向指定索引位置pos插入元素value，并将后面的元素顺序后移。默认pos的值是数组长度加1，即在数组尾部插入。如：

＞a={1, 2, 4}

＞table.insert(a, 3, 3)

＞table.insert(a, 5)

＞print(table.concat(a, ', '))

1, 2, 3, 4, 5

（3）从数组中弹出一个元素

table.remove(table [, pos])

从指定的索引删除一个元素，并将后面的元素前移，返回删除的元素值。默认pos的值是数组的长度，即从数组尾部弹出一个元素。如：

＞table.remove(a)

＞table.remove(a, 1)

＞print(table.concat(a, ', '))

2, 3, 4

3．Math库

Math库提供了常用的数学运算函数，如果参数是字符串会自动尝试转换成数字。具体的函数列表见表6-6。

表6-6  Math库的常用函数

续表

除此之外，Math库还提供了随机数函数：

math.random([m, [, n]])

math.randomseed(x)

math.random()函数用来生成一个随机数，根据参数不同其返回值范围也不同：

没有提供参数：返回范围在[0, 1)的实数；

只提供了m参数：返回范围在[1, m]的整数；

同时提供了m和n参数：返回范围在[m, n]的整数。

math.random函数生成的随机数是依据种子（seed）计算得来的伪随机数，意味着使用同一种子生成的随机数序列是相同的。可以使用math.randomseed()函数设置种子的值，例如：

＞math.randomseed(1)

＞print(math.random(1, 100))

1

＞print(math.random(1, 100))

14

＞print(math.random(1, 100))

76

＞math.randomseed(1)

＞print(math.random(1, 100))

1

＞print(math.random(1, 100))

14

＞print(math.random(1, 100))

76

6.2.3 其他库

除了标准库以外，Redis还通过cjson库① 和cmsgpack库② 提供了对JSON和MessagePack的支持。Redis自动加载了这两个库，在脚本中可以分别通过cjson和cmsgpack两个全局变量来访问对应的库。两者的用法如下：

注释：①见http://www.kyne.com.au/～mark/software/lua-cjson.php。

注释：②cmsgpack库的作者正是Redis的作者Salvatore Sanfilippo，其项目地址是https://github.com/antirez/lua-cmsgpack。

local people={

name='Bob',

age=29

}

--使用cjson序列化成字符串：

local json_people_str = cjson.encode(people)

--使用cmsgpack序列化成字符串：

local msgpack_people_str=cmsgpack.pack(people)

--使用cjson将序列化后的字符串还原成表：

local json_people_obj = cjson.decode(people)

print(json_people_obj.name)

--使用cmsgpack将序列化后的字符串还原成表：

local msgpack_people_obj = cmsgpack.unpack(people)

print(msgpack_people_obj.name)

6.3 Redis与Lua

编写Redis脚本的目的就是读写Redis的数据，本节将会介绍Redis与Lua交互的方法。

6.3.1 在脚本中调用Redis命令

在脚本中可以使用redis.call函数调用Redis命令。就像这样：

redis.call('set', 'foo', 'bar')

local value=redis.call('get', 'foo') --value的值为bar

redis.call函数的返回值就是Redis命令的执行结果。第2章介绍过Redis命令的返回值有5种类型，redis.call函数会将这5种类型的回复转换成对应的Lua的数据类型，具体的对应规则如表6-7所示（空结果比较特殊，其对应Lua的false）。

表6-7  Redis返回值类型和Lua数据类型转换规则

续表

Redis还提供了redis.pcall函数，功能与redis.call相同，唯一的区别是当命令执行出错时redis.pcall会记录错误并继续执行，而redis.call会直接返回错误，不会继续执行。

6.3.2 从脚本中返回值

在很多情况下都需要脚本可以返回值，比如前面的访问频率限制脚本会返回访问频率是否超限。在脚本中可以使用return语句将值返回给客户端，如果没有执行return语句则默认返回nil。因为我们可以像调用其他Redis内置命令一样调用我们自己写的脚本，所以同样Redis会自动将脚本返回值的Lua数据类型转换成Redis的返回值类型。具体的转换规则见表6-8（其中Lua的false比较特殊，会被转换成空结果）。

表6-8  Lua数据类型和Redis返回值类型转换规则

6.3.3 脚本相关命令

1．EVAL命令

编写完脚本后最重要的就是在程序中执行脚本。Redis提供了EVAL命令可以使开发者像调用其他Redis内置命令一样调用脚本。EVAL命令的格式是：EVAL脚本内容key参数的数量[key …] [arg …]。可以通过key和arg这两类参数向脚本传递数据，它们的值可以在脚本中分别使用KEYS和ARGV两个表类型的全局变量访问。例如，我们希望用脚本功能实现一个SET命令（当然现实中我们不会这么干），脚本内容是这样的：

return redis.call('SET', KEYS[1], ARGV[1])

现在打开redis-cli执行此脚本：

redis＞EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 foo bar

OK

redis＞GET foo

"bar"

其中要读写的键名应该作为key参数，其他的数据都作为arg参数。具体的原因会在6.4节中介绍。

注意  EVAL命令依据第二个参数将后面的所有参数分别存入脚本中KEYS和ARGV两个表类型的全局变量。当脚本不需要任何参数时也不能省略这个参数（设为O）。

2．EVALSHA命令

考虑到在脚本比较长的情况下，如果每次调用脚本都需要将整个脚本传给Redis会占用较多的带宽。为了解决这个问题，Redis提供了EVALSHA命令允许开发者通过脚本内容的SHA1摘要来执行脚本，该命令的用法和EVAL一样，只不过是将脚本内容替换成脚本内容的SHA1摘要。

Redis在执行EVAL命令时会计算脚本的SHA1摘要并记录在脚本缓存中，执行EVALSHA命令时Redis会根据提供的摘要从脚本缓存中查找对应的脚本内容，如果找到了则执行脚本，否则会返回错误：“NOSCRIPT No matching script. Please use EVAL.”

在程序中使用EVALSHA命令的一般流程如下。

（1）先计算脚本的SHA1摘要，并使用EVALSHA命令执行脚本。

（2）获得返回值，如果返回“NOSCRIPT”错误则使用EVAL命令重新执行脚本。

虽然这一流程略显麻烦，但值得庆幸的是很多编程语言的Redis客户端都会代替开发者完成这一流程。比如使用node_redis客户端执行EVAL命令时，node_redis会先尝试执行EVALSHA命令，如果失败了才会执行EVAL命令。

6.3.4 应用实例

本节会结合几个编程语言的Redis客户端，通过实例介绍在应用中如何使用脚本功能。

1．同时获取多个散列类型键的键值

假设有若干个用户的ID，现在需要获得这些用户的资料。用户的资料使用散列类型键存储，所以我们可以编写一个可以一次性对多个键执行HGETALL命令的脚本。

Predis将脚本功能抽象成了Redis的命令，我们可以通过脚本定义自己的命令并像调用其他命令一样调用我们自己写的脚本。首先我们定义HMGETALL（M表示多个的意思）类：

＜?php

class HMGetAll extends Predis\Command\ScriptedCommand

{

//定义前多少个参数会被作为KEYS变量

//false表示所有的参数

public function getKeysCount()

{

return false;

}

//返回脚本内容

public function getScript()

{

return

＜＜＜LUA

local result={}

for i, v in ipairs(KEYS) do

result[i]=redis.call('HGETALL', v)

end

return result

LUA;

}

}

client=new Predis\Client();

//定义hmgetall命令

client-＞getProfile()-＞defineCommand('hmgetall', 'HMGetAll');

//执行hmgetall命令

value=client-＞hmgetall('user:1', 'user:2', 'user:3');

2．获得并删除有序集合中分数最小的元素

列表类型提供了LPOP和RPOP两个命令实现弹出操作，然而有序集合类型却没有相应命令。不使用脚本功能的话必须借助事务来实现，比较繁琐，在Redis的官方文档中有这样的例子：

WATCH zset

element=ZRANGE zset 0 0

MULTI

ZREM zset element

EXEC

虽然代码不算长，但还要考虑事务执行失败（即执行WATCH命令后其他客户端修改了zset键）时必须重新执行。

redis-py客户端同样对EVAL和EVALSHA两个命令进行了抽象。首先使用register_script函数建立一个脚本对象，然后就可以使用该对象发送脚本命令了。代码如下：

r=redis.StrictRedis()

lua="""

local element=redis.call('ZRANGE', KEYS[1], 0, 0)[1]

if element then

redis.call('ZREM', KEYS[1], element)

end

return element

"""

ztop=r.register_script(lua)

#执行我们自己定义的ZTOP命令并打印出结果

print ztop(keys=['zset'])

3．处理JSON

3.2节介绍字符串类型时曾提到可以将对象JSON化后存入字符串类型键中。如果需要对这些对象进行计算，可以使用脚本在服务端完成计算后再返回，既节省了网络带宽，又保证了操作的原子性。

下面介绍使用脚本功能实现统计多个学生的课程分数总和。首先我们定义一个学生类，包括姓名和该学生的所有课程分数：

//学生类的构造函数，参数是学生姓名

function Student(name) {

this.name = name;

this.courses={};

}

//添加一个课程，参数为课程名和分数

Student.prototype.addCourse=function(name, score) {

this.courses[name] = score;

}

而后我们创建两个学生实例并为其添加课程：

//创建学生Bob，为其添加两门课程的成绩

var bob=new Student('Bob');

bob.addCourse('Mathematics', 80);

bob.addCourse('Literature', 95);

//创建学生Jeff，为其添加两门课程的成绩

var jeff=new Student('Jeff');

jeff.addCourse('Mathematics', 85);

jeff.addCourse('Chemistry', 70);

连接Redis，将两个实例JSON序列化后存入Redis中：

var redis=require("redis");

var client=redis.createClient();

//将两个对象JSON 序列化后存入数据库中

client.mset(

'user:1', JSON.stringify(bob),

'user:2', JSON.stringify(jeff)

);

现在开始进行最有趣的环节，即编写Lua脚本计算所有学生的所有课程的分数总和：

var lua=" \

local sum=0 \

local users=redis.call('mget', unpack(KEYS)) \

for _, user in ipairs(users) do \

local courses=cjson.decode(user).courses \

for _, score in pairs(courses) do \

sum=sum + score \

end \

end \

return sum \

";

接着调用node_redis的eval函数执行脚本，此函数会先计算脚本的SHA1摘要并尝试使用EVALSHA命令调用，如果失败就使用EVAL命令，这一过程对我们是透明的：

client.eval(lua, 2, 'user:1', 'user:2', function (err, sum) {

//结果是330

console.log(sum);

});

提示  因为在脚本中我们使用了unpack函数将KEYS表展开，所以执行脚本时我们可以传入任意数量的键参数，这是一个很有用的小技巧。

6.4 深入脚本

本节将深入探讨KEYS和ARGV两类参数的区别，以及脚本的沙盒限制和原子性等内容。

6.4.1 KEYS与ARGV

前面提到过向脚本传递的参数分为KEYS和ARGV两类，前者表示要操作的键名，后者表示非键名参数。但事实上这一要求并不是强制的，比如EVAL "return redis.call('get',KEYS[1])" 1 user:Bob可以获得user:Bob的键值，同样还可以使用EVAL "returnredis.call('get', 'user:' .. ARGV[1])" 0 Bob完成同样的功能，此时我们虽然并未按照Redis的规则使用KEYS参数传递键名，但还是获得了正确的结果。

虽然规则不是强制的，但不遵守规则依然有一定的代价。Redis将要发布的3.0版本会带有集群（cluster）功能，集群的作用是将数据库中的键分散到不同的节点上。这意味着在脚本执行前就需要知道脚本会操作哪些键以便找到对应的节点，所以如果脚本中的键名没有使用KEYS参数传递则无法兼容集群。

有时候键名是根据脚本某部分的执行结果生成的，这时就无法在执行前将键名明确标出。比如一个集合类型键存储了用户ID列表，每个用户使用散列键存储，其中有一个字段是年龄。下面的脚本可以计算某个集合中用户的平均年龄：

local sum=0

local users=redis.call('SMEMBERS', KEYS[1])

for _, user_id in ipairs(users) do

local user_age = redis.call('HGET', 'user:' .. user_id, 'age')

sum=sum + user_age

end

return sum / #users

这个脚本同样无法兼容集群功能（因为第4行中访问了KEYS变量中没有的键），但却十分实用，避免了数据往返客户端和服务端的开销。为了兼容集群，可以在客户端获取集合中的用户ID列表，然后将用户ID组装成键名列表传给脚本并计算平均年龄。两种方案都是可行的，至于实际采用哪种就需要开发者自行权衡了。

6.4.2 沙盒与随机数

Redis脚本禁止使用Lua标准库中与文件或系统调用相关的函数，在脚本中只允许对Redis的数据进行处理。并且Redis还通过禁用脚本的全局变量的方式保证每个脚本都是相对隔离的，不会互相干扰。

使用沙盒不仅是为了保证服务器的安全性，而且还确保了脚本的执行结果只和脚本本身和执行时传递的参数有关，不依赖外界条件（如系统时间、系统中某个文件的内容、其他脚本执行结果等）。这是因为在执行复制和AOF持久化（复制和持久化会在第7章介绍）操作时记录的是脚本的内容而不是脚本调用的命令，所以必须保证在脚本内容和参数一样的前提下脚本的执行结果必须是一样的。

除了使用沙盒外，为了确保执行的结果可以重现，Redis还对随机数和会产生随机结果的命令进行了特殊的处理。

对于随机数而言，Redis替换了math.random和math.randomseed函数使得每次执行脚本时生成的随机数列都相同，如果希望获得不同的随机数序列，最简单的方法是由程序生成随机数并通过参数传递给脚本，或者采用更灵活的方法，即在程序中生成随机数传给脚本作为随机数种子（通过math.randomseed(tonumber(ARGV[种子参数索引]))），这样在脚本中再调用math.random产生的随机数就不同了（由随机数种子决定）。

对于会产生随机结果的命令如SMEMBERS（因为集合类型是无序的）或HKEYS（因为散列类型的字段也是无序的）等Redis会对结果按照字典顺序排序。内部是通过调用Lua标准库的table.sort函数实现的，代码与下面这段很相似：

function __redis__compare_helper(a,b)

if a == false then a = '' end

if b == false then b = '' end

return a＜b

end

table.sort(result_array, __redis__compare_helper)

对于会产生随机结果但无法排序的命令（比如只会产生一个元素），Redis会在这类命令执行后将该脚本状态标记为lua_random_dirty，此后只允许调用只读命令，不允许修改数据库的值，否则会返回错误：“Write commands not allowed after non deterministiccommands.”属于此类的Redis命令有SPOP，SRANDMEMBER，RANDOMKEY和TIME。

6.4.3 其他脚本相关命令

除了EVAL和EVALSHA外，Redis还提供了其他4个脚本相关的命令，一般都会被客户端封装起来，开发者很少能使用到。

1．将脚本加入缓存：SCRIPT LOAD

每次执行EVAL命令时Redis都会将脚本的SHA1摘要加入到脚本缓存中，以便下次客户端可以使用EVALSHA命令调用该脚本。如果只是希望将脚本加入脚本缓存而不执行则可以使用SCRIPT LOAD命令，返回值是脚本的SHA1摘要。就像这样：

redis＞SCRIPT LOAD "return 1"

"e0e1f9fabfc9d4800c877a703b823ac0578ff8db"

2．判断脚本是否已经被缓存：SCRIPT EXISTS

SCRIPT EXISTS命令可以同时查找1个或多个脚本的SHA1摘要是否被缓存，如：

redis＞SCRIPT EXISTS e0e1f9fabfc9d4800c877a703b823ac0578ff8db abcdefghijklmnopqrst

uvwxyzabcdefghijklmn

1) (integer) 1

2) (integer) 0

3．清空脚本缓存：SCRIPT FLUSH

Redis将脚本的SHA1摘要加入到脚本缓存后会永久保留，不会删除，但可以手动使用SCRIPT FLUSH命令清空脚本缓存：

redis＞SCRIPT FLUSH

OK

4．强制终止当前脚本的执行：SCRIPT KILL

如果想终止当前正在执行的脚本可以使用SCRIPT KILL命令，下节还会提到这个命令。

6.4.4 原子性和执行时间

Redis的脚本执行是原子的，即脚本执行期间Redis不会执行其他命令。所有的命令都必须等待脚本执行完成后才能执行。为了防止某个脚本执行时间过长导致Redis无法提供服务（比如陷入死循环），Redis提供了lua-time-limit参数限制脚本的最长运行时间，默认为5秒钟。当脚本运行时间超过这一限制后，Redis将开始接受其他命令但不会执行（以确保脚本的原子性，因为此时脚本并没有被终止），而是会返回“BUSY”错误。现在我们打开两个redis-cli实例A和B来演示这一情况。首先在A中执行一个死循环脚本：

redis A＞EVAL "while true do end" 0

然后马上在B中执行一条命令：

redis B＞GET foo

这时实例B中的命令并没有马上返回结果，因为Redis已经被实例A发送的死循环脚本阻塞了，无法执行其他命令。等到脚本执行5秒钟后实例B收到了“BUSY”错误：

(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN

NOSAVE.

(3.74s)

此时Redis虽然可以接受任何命令，但实际会执行的只有两个命令：SCRIPT KILL和SHUTDOWN NOSAVE。

在实例B中执行SCRIPT KILL命令可以终止当前脚本的运行：

redis B＞SCRIPT KILL

OK

此时脚本被终止并且实例A中会返回错误：

(error) ERR Error running script (call f_694a5fe1ddb97a4c6a1bf299d9537c7d3d0f84e7):

Script killed by user with SCRIPT KILL...

(28.77s)

需要注意的是如果当前执行的脚本对Redis的数据进行了修改（如调用SET、LPUSH或DEL等命令）则SCRIPT KILL命令不会终止脚本的运行以防止脚本只执行了一部分。因为如果脚本只执行了一部分就被终止，会违背脚本的原子性要求，即脚本中的所有命令要么都执行，要么都不执行。比如在实例A中执行：

redis A＞EVAL "redis.call('SET', 'foo', 'bar') while true do end" 0

5秒钟后在实例B中尝试终止该脚本：

redis B＞SCRIPT KILL

(error) UNKILLABLE Sorry the script already executed write commands against the dataset.

You can either wait the script termination or kill the server in an hard way using

the SHUTDOWN NOSAVE command.

这时只能通过SHUTDOWN NOSAVE命令强行终止Redis。在第2章中我们介绍过使用SHUTDOWN命令退出Redis，而SHUTDOWN NOSAVE命令与SHUTDOWN命令的区别在于前者将不会进行持久化操作，这意味着所有发生在上一次快照（会在7.1节介绍）后的数据库修改都会丢失。

由于Redis脚本非常高效，所以在大部分情况下都不用担心脚本的性能。但同时由于脚本的强大功能，很多原本在程序中执行的逻辑都可以放到脚本中执行，这时就需要开发者根据具体应用权衡到底哪些任务适合交给脚本。通常来讲不应该在脚本中进行大量耗时的计算，因为毕竟Redis是单进程单线程执行脚本，而程序却能够多进程或多线程运行。

第7章 管理

虽然小白的博客已经运行有一段时间了，可是小白对如何管理Redis依然完全没有概念。比如他知道Redis将所有的数据都存储在内存中，那么如果服务器重启了，Redis中的数据怎么恢复呢？又如怎样给Redis设置密码以防其他未经授权的客户端连接呢？带着这些疑惑，小白再一次找到了宋老师。

本章将会讲解Redis的管理知识，包括持久化、复制和安全内容，同时还会介绍一些第三方的Redis管理工具。

7.1 持久化

Redis的强劲性能很大程度上是由于其将所有数据都存储在了内存中，为了使Redis在重启之后仍能保证数据不丢失，需要将数据从内存中以某种形式同步到硬盘中，这一过程就是持久化。

Redis支持两种方式的持久化，一种是RDB方式，一种是AOF方式。可以单独使用其中一种或将二者结合使用。

7.1.1 RDB方式

RDB方式的持久化是通过快照（snapshotting）完成的，当符合一定条件时Redis会自动将内存中的所有数据进行快照并存储在硬盘上。进行快照的条件可以由用户在配置文件中自定义，由两个参数构成：时间和改动的键的个数。当在指定的时间内被更改的键的个数大于指定的数值时就会进行快照。RDB是Redis默认采用的持久化方式，在配置文件中已经预置了3个条件：

save 900 1

save 300 10

save 60 10000

save参数指定了快照条件，可以存在多个条件，条件之间是“或”的关系。如上所说，save 900 1的意思是在15分钟（900秒钟）内有至少一个键被更改则进行快照。如果想要禁用自动快照，只需要将所有的save参数删除即可。

Redis默认会将快照文件存储在当前目录的dump.rdb文件中，可以通过配置dir和dbfilename两个参数分别指定快照文件的存储路径和文件名。

理清Redis实现快照的过程对我们了解快照文件的特性有很大的帮助。快照的过程如下。

（1）Redis使用fork函数复制一份当前进程（父进程）的副本（子进程）；

（2）父进程继续接收并处理客户端发来的命令，而子进程开始将内存中的数据写入硬盘中的临时文件；

（3）当子进程写入完所有数据后会用该临时文件替换旧的RDB文件，至此一次快照操作完成。

在执行fork的时候操作系统（类Unix操作系统）会使用写时复制（copy-on-write）策略，即fork函数发生的一刻父子进程共享同一内存数据，当父进程要更改其中某片数据时（如执行一个写命令），操作系统会将该片数据复制一份以保证子进程的数据不受影响，所以新的RDB文件存储的是执行fork一刻的内存数据。

通过上述过程可以发现Redis在进行快照的过程中不会修改RDB文件，只有快照结束后才会将旧的文件替换成新的，也就是说任何时候RDB文件都是完整的。这使得我们可以通过定时备份RDB文件来实现Redis数据库备份。RDB文件是经过压缩（可以配置rdbcompression参数以禁用压缩节省CPU占用）的二进制格式，所以占用的空间会小于内存中的数据大小，更加利于传输。

除了自动快照，还可以手动发送SAVE或BGSAVE命令让Redis执行快照，两个命令的区别在于，前者是由主进程进行快照操作，会阻塞住其他请求，后者会通过fork子进程进行快照操作。

Redis启动后会读取RDB快照文件，将数据从硬盘载入到内存。根据数据量大小与结构和服务器性能不同，这个时间也不同。通常将一个记录一千万个字符串类型键、大小为1GB的快照文件载入到内存中需要花费20～30秒钟。

通过RDB方式实现持久化，一旦Redis异常退出，就会丢失最后一次快照以后更改的所有数据。这就需要开发者根据具体的应用场合，通过组合设置自动快照条件的方式来将可能发生的数据损失控制在能够接受的范围。如果数据很重要以至于无法承受任何损失，则可以考虑使用AOF方式进行持久化。

7.1.2 AOF方式

默认情况下Redis没有开启AOF（append only file）方式的持久化，可以通过appendonly参数开启：

appendonly yes

开启AOF持久化后每执行一条会更改Redis中的数据的命令，Redis就会将该命令写入硬盘中的AOF文件。AOF文件的保存位置和RDB文件的位置相同，都是通过dir参数设置的，默认的文件名是appendonly.aof，可以通过appendfilename参数修改：

appendfilename appendonly.aof

下面讲解AOF持久化的具体实现，假设在开启AOF持久化的情况下执行了如下4个命令：

SET foo 1

SET foo 2

SET foo 3

GET foo

Redis会将前3条命令写入AOF文件中，此时AOF文件中的内容如下：

＊2

6

SELECT

1

0

＊3

3

set

3

foo

1

1

＊3

3

set

3

foo

1

2

＊3

3

set

3

foo

1

3

可见AOF文件是纯文本文件，其内容正是Redis客户端向Redis发送的原始通信协议的内容（Redis的通信协议会在7.4节中介绍，为了便于阅读，这里将实际的命令部分以粗体显示），从中可见Redis确实只记录了前3条命令。然而这时有一个问题是前2条命令其实都是冗余的，因为这两条的执行结果会被第三条命令覆盖。随着执行的命令越来越多，AOF文件的大小也会越来越大，即使内存中实际的数据可能并没有多少。很自然地，我们希望Redis可以自动优化AOF文件，就上例而言，就是将前两条无用的记录删除，只保留第三条。实际上Redis也正是这样做的，每当达到一定条件时Redis就会自动重写AOF文件，这个条件可以在配置文件中设置：

auto-aof-rewrite-percentage 100

auto-aof-rewrite-min-size 64mb

auto-aof-rewrite-percentage参数的意义是当目前的AOF文件大小超过上一次重写时的AOF文件大小的百分之多少时会再次进行重写，如果之前没有重写过，则以启动时的AOF文件大小为依据。auto-aof-rewrite-min-size参数限制了允许重写的最小AOF文件大小，通常在AOF文件很小的情况下即使其中有很多冗余的命令我们也并不太关心。除了让Redis自动执行重写外，我们还可以主动使用BGREWRITEAOF命令手动执行AOF重写。

上例中的AOF文件重写后的内容为：

＊2

6

SELECT

1

0

＊3

3

SET

3

foo

1

3

可见冗余的命令已经被删除了。重写的过程只和内存中的数据有关，和之前的AOF文件无关，这与RDB很相似，只不过二者的文件格式完全不同。

在启动时Redis会逐个执行AOF文件中的命令来将硬盘中的数据载入到内存中，载入的速度相较RDB会慢一些。

需要注意的是虽然每次执行更改数据库内容的操作时，AOF都会将命令记录在AOF文件中，但是事实上，由于操作系统的缓存机制，数据并没有真正地写入硬盘，而是进入了系统的硬盘缓存。在默认情况下系统每30秒会执行一次同步操作，以便将硬盘缓存中的内容真正地写入硬盘，在这30秒的过程中如果系统异常退出则会导致硬盘缓存中的数据丢失。一般来讲启用AOF持久化的应用都无法容忍这样的损失，这就需要Redis在写入AOF文件后主动要求系统将缓存内容同步到硬盘中。在Redis中我们可以通过appendfsync参数设置同步的时机：

# appendfsync always

appendfsync everysec

# appendfsync no

默认情况下Redis采用everysec 规则，即每秒执行一次同步操作。always表示每次执行写入都会执行同步，这是最安全也是最慢的方式。no表示不主动进行同步操作，而是完全交由操作系统来做（即每30秒一次），这是最快但最不安全的方式。一般情况下使用默认值everysec就足够了，既兼顾了性能又保证了安全。

Redis允许同时开启AOF和RDB，既保证了数据安全又使得进行备份等操作十分容易。此时重新启动Redis后Redis会使用AOF文件来恢复数据，因为AOF方式的持久化可能丢失的数据更少。

7.2 复制

通过持久化功能，Redis保证了即使在服务器重启的情况下也不会损失（或少量损失）数据。但是由于数据是存储在一台服务器上的，如果这台服务器的硬盘出现故障，也会导致数据丢失。为了避免单点故障，我们希望将数据库复制多个副本以部署在不同的服务器上，即使有一台服务器出现故障其他服务器依然可以继续提供服务。这就要求当一台服务器上的数据库更新后，可以自动将更新的数据同步到其他服务器上，Redis提供了复制（replication）功能可以自动实现同步的过程。

7.2.1 配置

同步后的数据库分为两类，一类是主数据库（master），一类是从数据库（slave）。主数据库可以进行读写操作，当发生写操作时自动将数据同步给从数据库。而从数据库一般是只读的，并接受主数据库同步过来的数据。一个主数据库可以拥有多个从数据库，而一个从数据库只能拥有一个主数据库，如图7-1所示。

图7-1 一个主数据库可以拥有多个从数据库

在Redis中使用复制功能非常容易，只需要在从数据库的配置文件中加入“slaveof主数据库IP主数据库端口”即可，主数据库无需进行任何配置。

为了能够更直观地展示复制的流程，下面将进行简单的演示。我们要在一台服务器上启动两个Redis实例，监听不同端口，其中一个作为主数据库，另一个作为从数据库。首先我们不加任何参数来启动一个Redis实例作为主数据库：

redis-server

该实例默认监听6379端口。然后加上slaveof参数启动另一个Redis实例作为从数据库，并让其监听6380端口：

redis-server --port 6380 --slaveof 127.0.0.1 6379

此时在主数据库中的任何数据变化都会自动同步到从数据库中。我们打开redis-cli实例A并连接到主数据库：

redis-cli

再打开redis-cli实例B并连接到从数据库：

redis-cli -p 6380

在实例A中使用SET命令设置一个键的值：

redis A＞SET foo bar

OK

此时在实例B中就可以获得该值了：

redis B＞GET foo

"bar"

但在默认情况下从数据库是只读的，如果直接修改从数据库的数据会出现错误：

redis B＞SET foo hi

(error) READONLY You can't write against a read only slave.

可以通过设置从数据库的配置文件中的slave-read-only为no以使从数据库可写，但是对从数据库的任何更改都不会同步给任何其他数据库，并且一旦主数据库中更新了对应的数据就会覆盖从数据库中的改动。

配置多台从数据库的方法也一样，在所有的从数据库的配置文件中都加上 slaveof参数指向同一个主数据库即可。

除了通过配置文件或命令行参数设置slaveof参数，还可以在运行时使用SLAVEOF命令修改：

redis＞SLAVEOF 127.0.0.1 6379

如果该数据库已经是其他主数据库的从数据库了，SLAVEOF命令会停止和原来数据库的同步转而和新数据库同步。还可以使用SLAVEOF NO ONE来使当前数据库停止接收其他数据库的同步转成主数据库。

7.2.2 原理

了解Redis复制的原理对日后运维有很大的帮助。

当一个从数据库启动后，会向主数据库发送SYNC命令，主数据库接收到SYNC命令后会开始在后台保存快照（即RDB持久化的过程），并将保存期间接收到的命令缓存起来。当快照完成后，Redis会将快照文件和所有缓存的命令发送给从数据库。从数据库收到后，会载入快照文件并执行收到的缓存的命令。当主从数据库断开重连后会重新执行上述操作，不支持断点续传。

实际的过程略微复杂一些，由于Redis服务器使用TCP协议通信，所以我们可以使用telnet工具伪装成一个从数据库来了解同步的具体过程。首先在命令行中连接主数据库（默认端口为6379，且没有任何从数据库连接）：

telnet 127.0.0.1 6379

Trying 127.0.0.1...

Connected to localhost.

Escape character is '^]'.

然后作为从数据库，我们先要发送PING命令确认主数据库是否可以连接：

PING

+PONG

主数据库会回复+PONG。如果没有收到主数据库的回复，则向用户提示错误。如果主数据库需要密码才能连接，我们还得发送AUTH命令进行验证（关于Redis的安全设置会在7.3节介绍）。而后向主数据库发送REPLCONF命令说明自己的端口号（这里随便选择了一个）：

REPLCONF listening-port 6381

+OK

这时就可以开始同步的过程了：向主数据库发送SYNC命令开始同步，此时主数据库发送回快照文件和缓存的命令。目前主数据库中只有一个foo键，所以收到的内容如下（快照文件是二进制格式，从第三行开始）：

SYNC

29

REDIS0006?foobar?6_?"

从数据库会将收到的内容写入到硬盘上的临时文件中，当写入完成后从数据库会用该临时文件替换RDB快照文件（RDB快照文件的位置就是持久化时配置的位置，由dir和dbfilename两个参数确定），之后的操作就和RDB持久化时启动恢复的过程一样了。需要注意的是在同步的过程中从数据库并不会阻塞，而是可以继续处理客户端发来的命令。默认情况下，从数据库会用同步前的数据对命令进行响应。可以配置slave-serve-stale-data参数为no来使从数据库在同步完成前对所有命令（除了INFO和SLAVEOF）都回复错误：“SYNC with master in progress.”

之后主数据库的任何数据变化都会同步给从数据库，同步的内容和Redis通信协议一样，比如我们在主数据库中执行SET foo hi，通过telnet我们收到了：

＊3

3

set

3

foo

2

hi

在复制的过程中，快照无论在主数据库还是从数据库中都起了很大的作用，只要执行复制就会进行快照，即使我们关闭了RDB方式的持久化（通过删除所有save参数）。更进一步，无论是否启用了RDB方式的持久化，Redis在启动时都会尝试读取dir和dbfilename两个参数指定的RDB文件来恢复数据库。

7.2.3 图结构

从数据库不仅可以接收主数据库的同步数据，自己也可以同时作为主数据库存在，形成类似图的结构，如图7-2所示，数据库A的数据会同步到B和C中，而B中的数据会同步到D和E中。向B中写入数据不会同步到A或C中，只会同步到D和E中。

图7-2 从数据库也可拥有从数据库

7.2.4 读写分离

通过复制可以实现读写分离以提高服务器的负载能力。在常见的场景中，读的频率大于写，当单机的Redis无法应付大量的读请求时（尤其是较耗资源的请求，比如SORT命令等）可以通过复制功能建立多个从数据库，主数据库只进行写操作，而从数据库负责读操作。

7.2.5 从数据库持久化

另一个相对耗时的操作是持久化，为了提高性能，可以通过复制功能建立一个（或若干个）从数据库，并在从数据库中启用持久化，同时在主数据库禁用持久化。当从数据库崩溃时重启后主数据库会自动将数据同步过来，所以无需担心数据丢失。而当主数据库崩溃时，需要在从数据库中使用SLAVEOF NO ONE命令将从数据库提升成主数据库继续服务，并在原来的主数据库启动后使用SLAVEOF命令将其设置成新的主数据库的从数据库，即可将数据同步回来。

7.3 安全

Redis的作者Salvatore Sanfilippo曾经发表过Redis宣言① ，其中提到Redis以简洁为美。同样在安全层面Redis也没有做太多的工作。

注释：①见http://oldblog.antirez.com/post/redis-manifesto.html。

7.3.1 可信的环境

Redis的安全设计是在“Redis运行在可信环境”这个前提下做出的，在生产环境运行时不能允许外界直接连接到Redis服务器上，而应该通过应用程序进行中转，运行在可信的环境中是保证Redis安全的最重要方法。

Redis的默认配置会接受来自任何地址发送来的请求，即在任何一个拥有公网IP的服务器上启动Redis服务器，都可以被外界直接访问到。要更改这一设置，在配置文件中修改bind参数，如只允许本机应用连接Redis，可以将bind参数改成：

bind 127.0.0.1

bind参数只能绑定一个地址② ，如果想更自由地设置访问规则需要通过防火墙来完成。

注释：②Redis可能会在2.8版本中支持绑定多个地址，参见https://github.com/antirez/redis/issues/274。

7.3.2 数据库密码

除此之外，还可以通过配置文件中的requirepass参数为Redis设置一个密码。例如：

requirepass TAFK(@~!ji^XALQ(sYh5xIwTn5Ds7JF

客户端每次连接到Redis时都需要发送密码，否则Redis会拒绝执行客户端发来的命令。例如：

redis＞GET foo

(error) ERR operation not permitted

发送密码需要使用AUTH命令，就像这样：

redis＞AUTH TAFK(@~!ji^XALQ(sYh5xIwTn5Ds7JF

OK

之后就可以执行任何命令了：

redis＞GET foo

" 1"

由于Redis的性能极高，并且输入错误密码后Redis并不会进行主动延迟（考虑到Redis的单线程模型），所以攻击者可以通过穷举法破解Redis的密码（1秒内能够尝试十几万个密码），因此在设置时一定要选择复杂的密码。

提示  配置Redis复制的时候如果主数据库设置了密码，需要在从数据库的配置文件中通过masterauth参数设置主数据库的密码，以使从数据库连接主数据库时自动使用AUTH命令认证。

7.3.3 命名命令

Redis支持在配置文件中将命令重命名，比如将FLUSHALL命令重命名成一个比较复杂的名字，以保证只有自己的应用可以使用该命令。就像这样：

rename-command FLUSHALL oyfekmjvmwxq5a9c8usofuo369x0it2k

如果希望直接禁用某个命令可以将命令重命名成空字符串：

rename-command FLUSHALL ""

注意  无论设置密码还是重命名命令，都需要保证配置文件的安全性，否则就没有任何意义了。

7.4 通信协议

Redis通信协议是Redis客户端与Redis之间交流的语言，通信协议规定了命令和返回值的格式。了解Redis通信协议后不仅可以理解AOF文件的格式和主从复制时主数据库向从数据库发送的内容等，还可以开发自己的Redis客户端（不过由于几乎所有常用的语言都有相应的Redis客户端，需要使用通信协议直接和Redis打交道的机会确实不多）。

Redis支持两种通信协议，一种是二进制安全的统一请求协议（unified request protocol），一种是比较直观的便于在telnet程序中输入的简单协议。这两种协议只是命令的格式有区别，命令返回值的格式是一样的。

7.4.1 简单协议

简单协议适合在telnet程序中和Redis通信。简单协议的命令格式就是将命令和各个参数使用空格分隔开，如“EXISTS foo”、“SET foo bar”等。由于Redis解析简单协议时只是简单地以空格分隔参数，所以无法输入二进制字符。我们可以通过telnet程序测试：

telnet 127.0.0.1 6379

Trying 127.0.0.1...

Connected to localhost.

Escape character is '^]'.

SET foo bar

+OK

GET foo

3

bar

LPUSH plist 1 2 3

:3

LRANGE plist 0 -1

＊3

1

3

1

2

1

1

ERRORCOMMAND

-ERR unknown command 'ERRORCOMMAND'

提示  Redis 2.4之前的版本对于某些命令可以使用类似简单协议的特殊方式输入二进制安全的参数，例如：

C:SET foo 3

C:bar

S:+OK

其中C:表示客户端发出的内容，S:表示服务端发出的内容。第一行的最后一个参数表示字符串的长度，第二行是字符串的实际内容，因为指定了长度，所以第二行的字符串可以包含二进制字符。但是这个协议已经废弃，被新的统一请求协议取代。“统一”二字指所有的命令使用同样的请求方式而不再为某些命令使用特殊方式，如果需要在参数中包含二进制字符应该使用7.4.2节介绍的统一请求协议。

我们在telnet程序中输入的5条命令恰好展示了Redis的5种返回值类型的格式，2.3.2节介绍了这5种返回值类型在redis-cli中的展现形式，这些展现形式是经过了redis-cli封装的，而上面的内容才是Redis真正返回的格式。下面分别介绍。

1．错误回复

错误回复（error reply）以-开头，并在后面跟上错误信息，最后以\r\n结尾：

-ERR unknown command 'ERRORCOMMAND'\r\n

2．状态回复

状态回复（status reply）以+开头，并在后面跟上状态信息，最后以\r\n结尾：

+OK\r\n

3．整数回复

整数回复（integer reply）以:开头，并在后面跟上数字，最后以\r\n结尾：

:3\r\n

4．字符串回复

字符串回复（bulk reply）以开头，并在后面跟上字符串的长度，并以\r\n分隔，接着是字符串的内容和\r\n：

3\r\nbar\r\n

如果返回值是空结果nil，则会返回-1以和空字符串相区别。

5．多行字符串回复

多行字符串回复（multi-bulk reply）以＊开头，并在后面跟上字符串回复的组数，并以\r\n分隔。接着后面跟的就是字符串回复的具体内容了：

＊3\r\n1\r\n3\r\n1\r\n2\r\n1\r\n1\r\n

7.4.2 统一请求协议

统一请求协议是从Redis 1.2开始加入的，其命令格式和多行字符串回复的格式很类似，如SET foo bar的统一请求协议写法是“*3\r\n3\r\nSET\r\n3\r\nfoo\r\n3\r\nbar\r\n”。还是使用telnet进行演示：

telnet 127.0.0.1 6379

Trying 127.0.0.1...

Connected to localhost.

Escape character is '^]'.

＊3

3

SET

3

foo

3

bar

+OK

同样发送命令时指定了后面字符串的长度，所以命令的每个参数都可以包含二进制的字符。统一请求协议的返回值格式和简单协议一样，这里不再赘述。

Redis的AOF文件和主从复制时主数据库向从数据库发送的内容都使用了统一请求协议。如果要开发一个和Redis直接通信的客户端，推荐使用此协议。如果只是想通过telnet向Redis服务器发送命令则使用简单协议就可以了。

7.5 管理工具

工欲善其事，必先利其器。在使用Redis的时候如果能够有效利用Redis的各种管理工具，将会大大方便开发和管理。

7.5.1 redis-cli

相信大家对redis-cli已经很熟悉了，作为Redis自带的命令行客户端，你可以从任何安装有Redis的服务器中找到它，所以对于管理Redis而言redis-cli是最简单实用的工具。

redis-cli可以执行大部分的Redis命令，包括查看数据库信息的INFO命令，更改数据库设置的CONFIG命令和强制进行RDB快照的SAVE命令等，下面会介绍几个管理Redis时非常有用的命令。

1．耗时命令日志

当一条命令执行时间超过限制时，Redis会将该命令的执行时间等信息加入耗时命令日志（slow log）以供开发者查看。可以通过配置文件的slowlog-log-slower-than参数设置这一限制，要注意单位是微秒（1000000微秒相当于1秒），默认值是10000。耗时命令日志存储在内存中，可以通过配置文件的slowlog-max-len参数来限制记录的条数。

使用SLOWLOG GET命令来获得当前的耗时命令日志，如：

redis＞SLOWLOG GET

1) 1) (integer) 4

2) (integer) 1356806413

3) (integer) 58

4) 1) "get"

2) "foo"

2) 1) (integer) 3

2) (integer) 1356806408

3) (integer) 34

4) 1) "set"

2) "foo"

3) "bar"

每条日志都由以下4个部分组成：

（1）该日志唯一ID；

（2）该命令执行的UNIX时间；

（3）该命令的耗时时间，单位是微秒；

（4）命令及其参数。

提示  为了产生一些耗时命令日志作为演示，这里将slowlog-log-slower-than参数值设置为O，即记录所有命令。如果设置为负数则会关闭耗时命令日志。

2．命令监控

Redis提供了MONITOR命令来监控Redis执行的所有命令，redis-cli同样支持这个命令，如在redis-cli中执行MONITOR：

redis＞MONITOR

OK

这时Redis执行的任何命令都会在redis-cli中打印出来，如我们打开另一个redis-cli执行SET foo bar命令，在之前的redis-cli中会输出如下内容：

1356806981.885237 [0 127.0.0.1:57339] "SET" "foo" "bar

MONITOR命令非常影响Redis的性能，一个客户端使用MONITOR命令会降低 Redis将近一半的负载能力。所以MONITOR命令只适合用来调试和纠错。

补充知识  Instagram① 团队开发了一个基于MONITOR命令的Redis查询分析程序redis-faina。redis-faina可以根据MONITOR命令的监控结果分析出最常用的命令、访问最频繁的键等信息，对了解Redis的使用情况帮助很大。

注释：①Instagram是Facebook旗下的图片分享社区。

redis-faina的项目地址是https://github.com/Instagram/redis-faina，直接下载其中的redis-faina.py文件即可使用。

redis-faina.py的输入值为一段时间的MONITOR命令执行结果。例如：

redis-cli MONITOR | head -n ＜要分析的命令数＞ | ./redis-faina.py

7.5.2 phpRedisAdmin

当Redis中的键较多时，使用redis-cli管理数据并不是很方便，就如同管理MySQL时有人喜欢使用phpMyAdmin一样，Redis同样有一个PHP开发的网页端管理工具phpRedisAdmin。phpRedisAdmin支持以树形结构查看键列表，编辑键值，导入/导出数据库数据，查看数据库信息和查看键信息等功能。

1．安装phpRedisAdmin

安装phpRedisAdmin的方法如下：

git clone https://github.com/ErikDubbelboer/phpRedisAdmin.git

cd phpRedisAdmin

phpRedisAdmin依赖PHP的Redis客户端Predis，所以还需要执行下面两个命令下载Predis：

git submodule init

git submodule update

2．配置数据库连接

下载完phpRedisAdmin后需要配置Redis的连接信息。默认phpRedisAdmin会连接到127.0.0.1，端口6379，如果需要更改或者添加数据库信息可以编辑includes文件夹中的config.inc. php文件。

3．使用phpRedisAdmin

安装PHP和Web服务器（如Nginx），并将phpRedisAdmin文件夹存放到网站目录中即可访问，如图7-3所示。

图7-3 phpRedisAdmin界面

phpRedisAdmin自动将Redis的键以“:”分隔并用树形结构显示出来，十分直观。如post:1和post:2两个键都在post树中。

点击一个键后可以查看键的信息，包括键的类型、生存时间及键值，并且可以很方便地编辑。

4．性能

phpRedisAdmin在获取键列表时使用的是KEYS＊命令，然后对所有的键使用TYPE命令来获取其数据类型，所以当键非常多的时候性能并不高（对于一个有一百万个键的Redis数据库，在一台普通个人计算机上使用KEYS＊命令大约会花费几十毫秒）。由于Redis使用单线程处理命令，所以对生产环境下拥有大数据量的数据库来说不适宜使用phpRedisAdmin管理。

图7-4 查看键信息

7.5.3 Rdbtools

Rdbtools是一个Redis的快照文件解析器，它可以根据快照文件导出JSON数据文件、分析Redis中每个键的占用空间情况等。Rdbtools是使用Python开发的，项目地址是https://github.com/sripathikrishnan/redis-rdb-tools。

1．安装Rdbtools

使用如下命令安装Rdbtools：

git clone https://github.com/sripathikrishnan/redis-rdb-tools

cd redis-rdb-tools

sudo python setup.py install

2．生成快照文件

如果没有启用RDB持久化，可以使用SAVE命令手动使Redis生成快照文件。

3．将快照导出为JSON格式

快照文件是二进制格式，不利于查看，可以使用Rdbtools来将其导出为JSON格式，命令如下：

rdb --command json /path/to/dump.rdb ＞ output_filename.json

其中/path/to/dump.rdb是快照文件的路径，output_filename.json为要导出的文件路径。

4．生成空间使用情况报告

Rdbtools能够将快照文件中记录的每个键的存储情况导出为CSV文件，可以将该CSV文件导入到Excel等数据分析工具中分析来了解Redis的使用情况。命令如下：

rdb -c memory /path/to/dump.rdb ＞ output_filename.csv

导出的CSV文件的字段及说明如表7-1所示。

表7-1  Rdbtools导出的CSV 文件字段说明

附录A Redis命令属性

Redis的不同命令拥有不同的属性，如是否是只读命令，是否是管理员命令等，一个命令可以拥有多个属性。在一些特殊情况下不同属性的命令会有不同的表现，下面来逐一介绍。

A.1 REDIS_CMD_WRITE

拥有REDIS_CMD_WRITE属性的命令的表现是会修改Redis数据库的数据。一个只读的从数据库会拒绝执行拥有REDIS_CMD_WRITE属性的命令，另外在Lua脚本中执行了拥有REDIS_CMD_RANDOM属性（见A.4）的命令后，不可以再执行拥有REDIS_CMD_WRITE属性的命令，否则会提示错误：“Write commands not allowed after non deterministic commands.”

拥有REDIS_CMD_WRITE 属性的命令如下：

SET

SETNX

SETEX

PSETEX

APPEND

DEL

SETBIT

SETRANGE

INCR

DECR

RPUSH

LPUSH

RPUSHX

LPUSHX

LINSERT

RPOP

LPOP

BRPOP

BRPOPLPUSH

BLPOP

LSET

LTRIM

LREM

RPOPLPUSH

SADD

SREM

SMOVE

SPOP

SINTERSTORE

SUNIONSTORE

SDIFFSTORE

ZADD

ZINCRBY

ZREM

ZREMRANGEBYSCORE

ZREMRANGEBYRANK

ZUNIONSTORE

ZINTERSTORE

HSET

HSETNX

HMSET

HINCRBY

HINCRBYFLOAT

HDEL

INCRBY

DECRBY

INCRBYFLOAT

GETSET

MSET

MSETNX

MOVE

RENAME

RENAMENX

EXPIRE

EXPIREAT

PEXPIRE

PEXPIREAT

FLUSHDB

FLUSHALL

SORT

PERSIST

RESTORE

MIGRATE

BITOP

A.2 REDIS_CMD_DENYOOM

拥有REDIS_CMD_DENYOOM属性的命令有可能增加Redis占用的存储空间，显然拥有该属性的命令都拥有REDIS_CMD_WRITE属性，但反之则不然。例如，DEL命令拥有REDIS_CMD_WRITE属性，但其总是会减少数据库的占用空间，所以不拥有REDIS_CMD_DENYOOM属性。

当数据库占用的空间达到了配置文件中maxmemory参数指定的值且根据maxmemory-policy参数的空间释放规则无法释放空间时，Redis会拒绝执行拥有REDIS_CMD_DENYOOM属性的命令。

提示  拥有REDIS_CMD_DENYOOM属性的命令每次调用时不一定都会使数据库的占用空间增大，只是有可能而已。例如，SET命令当新值长度小于旧值时反而会减少数据库的占用空间。但无论如何，当数据库占用空间超过限制时，Redis都会拒绝执行拥有REDIS_CMD_DENYOOM属性的命令，而不会分析其实际上是不是会真的增加空间占用。

拥有REDIS_CMD_DENYOOM属性的命令如下：

SET

SETNX

SETEX

PSETEX

APPEND

SETBIT

SETRANGE

INCR

DECR

RPUSH

LPUSH

RPUSHX

LPUSHX

LINSERT

BRPOPLPUSH

LSET

RPOPLPUSH

SADD

SINTERSTORE

SUNIONSTORE

SDIFFSTORE

ZADD

ZINCRBY

ZUNIONSTORE

ZINTERSTORE

HSET

HSETNX

HMSET

HINCRBY

HINCRBYFLOAT

INCRBY

DECRBY

INCRBYFLOAT

GETSET

MSET

MSETNX

SORT

RESTORE

BITOP

A.3 REDIS_CMD_NOSCRIPT

拥有REDIS_CMD_NOSCRIPT属性的命令无法在Redis脚本中执行。

提示  EVAL和EVALSHA命令也拥有该属性，所以在脚本中无法调用这两个命令，即不能在脚本中调用脚本。

拥有REDIS_CMD_NOSCRIPT属性的命令如下：

BRPOP

BRPOPLPUSH

BLPOP

SPOP

AUTH

SAVE

MULTI

EXEC

DISCARD

SYNC

REPLCONF

MONITOR

SLAVEOF

DEBUG

SUBSCRIBE

UNSUBSCRIBE

PSUBSCRIBE

PUNSUBSCRIBE

WATCH

UNWATCH

EVAL

EVALSHA

SCRIPT

A.4 REDIS_CMD_RANDOM

当一个脚本执行了拥有REDIS_CMD_RANDOM属性的命令后，就不能执行拥有REDIS_CMD_WRITE属性的命令了（见6.4.2节介绍）。

拥有REDIS_CMD_RANDOM的命令如下：

SPOP

SRANDMEMBER

RANDOMKEY

TIME

A.5 REDIS_CMD_SORT_FOR_SCRIPT

拥有REDIS_CMD_SORT_FOR_SCRIPT属性的命令会产生随机结果（见6.4.2节），在脚本中调用这些命令时Redis会对结果进行排序。

拥有REDIS_CMD_SORT_FOR_SCRIPT属性的命令如下：

SINTER

SUNION

SDIFF

SMEMBERS

HKEYS

HVALS

KEYS

A.6 REDIS_CMD_LOADING

当Redis正在启动时（将数据从硬盘载入到内存中），Redis只会执行拥有REDIS_CMD_LOADING属性的命令。

拥有REDIS_CMD_LOADING属性的命令如下：

INFO

SUBSCRIBE

UNSUBSCRIBE

PSUBSCRIBE

PUNSUBSCRIBE

PUBLISH

附录B 配置参数索引

本附录列出了Redis中部分配置参数的章节索引，具体见表B-1。

表B-1  Redis部分配置参数列表及章节索引

续表