Redis与Java - 实践

Redis与Java - 实践

标签 : Java与NoSQL


Transaction

Redis事务(transaction)是一组命令的集合,同命令一样也是Redis的最小执行单位, Redis保证一个事务内的命令执行不被其他命令影响.

`MULTI`
    SADD user:1:following 2
    SADD user:2:follower 1
`EXEC`

vs. RDBMS

事务操作 MySQL Redis
开启 start transaction MULTI
语句 DML 普通命令
取消 rollback DISCARD
执行 commit EXEC
  • MySQL的rollback与Redis的DISCARD有一定的区别.
    假设现在已经成功执行了事务内的前2条语句, 第3条语句出错:
    1. MySQLrollback后,前2条的语句影响消失.
    2. Redis可以分为两种情况:
      • 语法错误: 事务中断, 所有语句均得不到执行;
      • 运行错误: (如语法正确,但适用数据类型不对: 像ZADD操作List), EXEC会执行前2条语句, 并跳过第3条语句.
        这样的部分成功会导致数据不一致, 而这一点需要由开发人员负责, 比如提前规划好缓存key的设计.

乐观锁与WATCH

悲观锁(Pessimistic Lock): 很悲观,每次读写数据都认为别人会修改,所以每次读数据都会上锁,这样如果别人也想读写这条数据就会阻塞, 直到加锁的人把锁释放. 传统的RDBMS中用到了很多这种锁机制, 如行锁表锁读锁写锁等.
乐观锁(Optimistic Lock): 顾名思义非常乐观, 每次读写数据时候都认为别人不会修改,所以不再上锁,但在更新数据时会判断一下在此期间有没有人更新了这条数据, 这个判断过程可以使用版本号等机制实现, 而Redis默认就对乐观锁提供了支持 –WATCH命令.

WATCH命令可以监控一个/多个key, 一旦其中有一个被修改/删除, 则之后的事务就不会执行,如用WATCH命令来模拟抢票场景:

SET ticket 1        # 现在假设只有一张票了
`WATCH` ticket      # 监控票数变化
`MULTI`
    DECRBY username 400
    DECR ticket
        [DECR ticket]   # 现在假设有另外一个用户直接把这张票买走了
`EXEC`
    -> `(nil)`  # 则这条事务执行就不会成功

小结

  • WATCH命令的作用只是当被监控的key值修改后阻止事务执行,并不能阻止其他Client修改. 所以一旦EXEC执行失败, 可以重新执行整个方法或使用UNWATCH命令取消监控.

  • 乐观锁适用于读多写少情景,即冲突真的很少发生,这样可以省去大量锁的开销. 但如果经常产生冲突,上层应用需要不断的retry,反倒是降低了性能,所以这种情况悲观锁比较适用.


Expire & Cache

Redis可以使用EXPIRE命令设置key的过期时间, 到期后Redis会自动删除它.

命令 作用
EXPIRE key seconds Set a timeout on key.
TTL key Get the time to live for a key
PERSIST key Remove the expiration for a key

除了PERSIST命令之外,SET/GETSETkey赋值的同时也会清除key的过期时间.另外如果WATCH监控了一个拥有过期时间的key,key到期自动删除并不会被WATCH认为该key被修改.

  • 缓存DB数据
    为了提高网站负载能力,常需要将一些访问频率较高但对CPU/IO消耗较大的操作结果缓存起来,并希望让这些缓存过期自动删除, 下面我们就使用Redis缓存DB数据, 场景介绍可以参考:Memcached - In Action:缓存DB查询数据.
/**
 * @author jifang.
 * @since 2016/6/13 20:08.
 */
public class RedisDAO {

    private static final int _1M = 60 * 1000;

    private static final DataSource dataSource;

    private static final Jedis redis;

    static {
        Properties properties = new Properties();
        try {
            properties.load(ClassLoader.getSystemResourceAsStream("db.properties"));
        } catch (IOException ignored) {
        }

        /** 初始化连接池 **/
        HikariConfig config = new HikariConfig();
        config.setDriverClassName(properties.getProperty("mysql.driver.class"));
        config.setJdbcUrl(properties.getProperty("mysql.url"));
        config.setUsername(properties.getProperty("mysql.user"));
        config.setPassword(properties.getProperty("mysql.password"));
        dataSource = new HikariDataSource(config);

        /** 初始化Redis **/
        redis = new Jedis(properties.getProperty("redis.host"), Integer.valueOf(properties.getProperty("redis.port")));
    }

    public List<Map<String, Object>> executeQuery(String sql) {
        List<Map<String, Object>> result;
        try {
            /** 首先请求Redis **/
            String key = sql.replace(' ', '-');
            String string = redis.get(key);

            // 如果key未命中, 再请求DB
            if (string == null || string.trim().isEmpty()) {
                ResultSet resultSet = dataSource.getConnection().createStatement().executeQuery(sql);

                /** 获得列数/列名 **/
                ResultSetMetaData meta = resultSet.getMetaData();
                int columnCount = meta.getColumnCount();
                List<String> columnName = new ArrayList<>();
                for (int i = 1; i <= columnCount; ++i) {
                    columnName.add(meta.getColumnName(i));
                }

                /** 填充实体 **/
                result = new ArrayList<>();
                while (resultSet.next()) {
                    Map<String, Object> entity = new HashMap<>(columnCount);
                    for (String name : columnName) {
                        entity.put(name, resultSet.getObject(name));
                    }
                    result.add(entity);
                }

                /**写入Redis**/
                String value = JSON.toJSONString(result);
                redis.set(key, value, "NX", "PX", _1M);
            } else {
                result = JSON.parseObject(string, List.class);
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
        return result;
    }

    public static void main(String[] args) {
        RedisDAO dao = new RedisDAO();
        List<Map<String, Object>> execute = dao.executeQuery("select * from user");
        System.out.println(execute);
    }
}

当服务器内存有限时,如果大量使用缓存而且过期时间较长会导致Redis占满内存; 另一方面为了防止占用内存过大而设置过期时间过短, 则有可能导致缓存命中率过低而使系统整体性能下降.因此为缓存设计一个合理的过期时间是很纠结的, 在Redis中可以限制能够使用的最大内存,并让Redis按照一定规则的淘汰不再需要的key: 修改maxmemory参数,当超过限制会依据maxmemory-policy参数指定的策略来删除不需要的key:

maxmemory-policy 规则说明
volatile-lru 只对设置了过期时间的key使用LRU算法删除
allkey-lru 使用LRU删除一个key
volatile-random 只对设置了过期时间的key随机删除一个key
allkey-random 随机删除一个key
volatile-ttl 删除过期时间最近的一个key
noevication 不删除key, 只返回错误(默认)

Sort

Redis的SORT命令可以对ListSetSorted-Set类型排序, 并且可以完成与RDBMS 连接查询 类似的任务:

SORT key    [BY pattern] 
            [LIMIT offset count] 
            [GET pattern [GET pattern ...]] 
            [ASC|DESC] 
            [ALPHA] 
            [STORE destination]
参数 描述
ALPHA SORT默认会将所有元素转换成双精度浮点数比较,无法转换则会提示错误,而使用ALPHA参数可实现按字典序比较.
DESC 降序排序(SORT默认升序排序).
LIMIT 指定返回结果范围.
STORE SORT默认直接返回排序结果, STORE可将排序后结果保存为List.

注: SORT在对Sorted-Set排序时会忽略元素分数,只针对元素自身值排序.


BY

很多情况下key实际存储的是对象ID, 有时单纯对ID自身排序意义不大,这就用到了BY参数, 对ID关联的对象的某个属性进行排序:

[BY pattern]

pattern可以是字符串类型keyHash类型key的某个字段(表示为键名 -> 字段名).如果提供了BY参数, SORT将使用ID值替换参考key中的第一个*并获取其值,然后根据该值对元素排序.

SORT mi.blog:1:my BY mi.blog:*:data->time DESC
  • 注意:
    • pattern不包含*时, SORT将不会执行排序操作;
    • 当ID元素的参考key不存在时,默认设置为0;
    • 如果几个ID元素的pattern值相同,则会再比较元素本身值排序.

GET

GET参数不影响排序过程,它的作用是使SORT返回结果不再是元素自身的值,而是GET参数指定的键值:

[GET pattern [GET pattern ...]]

BY一样, GET参数也支持String类型和Hash类型, 并使用*作为占位符.

SORT mi.blog:1:my BY mi.blog:*:data->time GET mi.blog:*:data->content GET mi.blog:*:data->time

注: GET参数获取自身值需要使用#: GET #


性能

SORT的时间复杂度为O(N+M*log(M)):

where N is the number of elements in the list or set to sort, and M the number of returned elements. When the elements are not sorted, complexity is currently O(N) as there is a copy step that will be avoided in next releases.
  • 所以开发过程中使用SORT需要注意:
    1. 尽可能减小待排序key中元素数量(减小N);
    2. 使用LIMIT参数限制结果集大小(减小M);
    3. 如果待排序数据量较大,尽可能使用STORE将结果缓存.

Message

1. 消息队列

消息队列就是”传递消息的队列”,与消息队列进行交互的实体有两类, 一是生产者: 将需要处理的消息放入队列; 一是消费者: 不断从消息队列中读出消息并处理.

使用消息队列有如下好处:
松耦合: 生产者和消费者无需知道彼此的实现细节, 只需按照协商好的消息格式读/写, 即可实现不同进程间通信,这就使得生产者和消费者可以由不同的团队使用不同的开发语言编写.
易扩展: 消费者可以有多个,且可以分布在不同的Server中, 降低单台Server负载, 横向扩展业务.

Redis提供了BRPOP/BLPOP命令来实现消息队列:

命令 描述
BRPOP key [key ...] timeout Remove and get the last element in a list, or block until one is available
BLPOP key [key ...] timeout Remove and get the first element in a list, or block until one is available
BRPOPLPUSH source destination timeout Pop a value from a list, push it to another list and return it; or block until one is available

注: 若Redis同时监听多个key, 且每个key均有元素可取,则Redis按照从左到右的顺序去挨个读取key第一个元素.


2. 消息订阅

前面的BRPOP/BLPOP实现的消息队列有一个限制: 如果一个队列被多个消费者监听, 生产者发布一条消息只会被其中一个消费者获取. 因此Redis还提供了一组命令实现“发布/订阅”模式, 同样可用于进程间通信:

“发布/订阅”模式也包含两种角色: 发布者与订阅者. 订阅者可以订阅一个/多个频道, 而发布者可向指定频道发送消息, 所有订阅此频道的订阅者都会收到此消息.

命令 描述
PUBLISH channel message Post a message to a channel
SUBSCRIBE channel [channel ...] Listen for messages published to the given channels
UNSUBSCRIBE [channel [channel ...]] Stop listening for messages posted to the given channels
PSUBSCRIBE pattern [pattern ...] Listen for messages published to channels matching the given patterns
PUNSUBSCRIBE [pattern [pattern ...]] Stop listening for messages posted to channels matching the given patterns
  • MessagesQueue
/**
 * @author jifang
 * @since 16/7/11 下午2:36.
 */
public class MessageQueue<T> {

    private Jedis redis;

    private String chanel;

    public MessageQueue(Jedis redis, String chanel) {
        this.redis = redis;
        this.chanel = chanel;
    }

    public Long publish(T message) {
        String json = JSON.toJSONString(message);
        return redis.publish(chanel, json);
    }

    public void subscribe(final MessageHandler<T> handler) {
        redis.subscribe(new JedisPubSub() {
            @Override
            public void onMessage(String channel, String message) {
                for (Type type : handler.getClass().getGenericInterfaces()) {
                    if (type instanceof ParameterizedType) {
                        ParameterizedType pType = (ParameterizedType) type;
                        Type handlerClass = pType.getActualTypeArguments()[0];
                        T result = JSONObject.parseObject(message, handlerClass);
                        handler.handle(result);
                    }
                }
            }
        }, chanel);
    }
}
public interface MessageHandler<T> {
    void handle(T object);
}

注: 发送的消息不会持久化,一个订阅者只能接收到后续发布的消息,之前发送的消息就接收不到了.


持久化

Redis支持两种持久化方式: RDB与AOF. RDB: Redis根据指定的规则“定时”将内存数据快照到硬盘; AOF:Redis在每次执行命令后将命令本身记录下来存放到硬盘.两种持久化方式可结合使用.


RDB

  • 快照执行过程:
    • Redis使用fork()函数复制一份当前进程副本;
    • 父进程继续接收并处理客户端请求, 而子进程将所有内存数据写入磁盘临时文件;
    • 当子进程将所有数据写完会用该临时文件替换旧的RDB文件, 至此一次快照完成(可以看到自始至终RDB文件都是完整的).

Redis会在以下几种情况下对数据进行快照:

  • 根据配置规则
    配置由两个参数构成: 时间窗口M和改动key个数N; 当时间M内被改动的key的个数大于N时, 即符合自动快照条件:
save 900 1
save 300 10
save 60 10000
  • 用户执行SAVE/BGSAVE/FLUSHALL命令:
    除了让Redis自动快照, 当进行服务重启/手动迁移以及备份时也需要我们手动执行快照.
命令 描述
SAVE SAVE命令会使Redis同步地执行快照操作(过程中会阻塞所有来自客户端的请求, 因此尽量避免线上使用)
BGSAVE 在后台异步执行快照操作,Redis还可继续响应请求
FLUSHALL FLUSHALL会清空所有数据,无论是否触发了自动快照条件(只要有配置了),Redis都会执行一次快照
LASTSAVE 获取最近一次成功执行快照时间
  • 执行复制
    当设置了主从模式, Redis会在复制初始化时执行快照,即使没有配置自动快照条件.

通过RDB方式实现持久化, Redis在启动后会读取RDB快照文件, 将数据从硬盘导入内存, 但如果在持久化过程中Redis异常退出, 就会丢失最后一次快照以后更改的所有数据.


RDB其他配置参数

dir ./                  # 设置工作目录,RDB文件(以及后面的AOF文件)会写入该目录
dbfilename dump.rdb     # 设置RDB文件名
rdbcompression yes      # 导出RDB是否压缩
rdbchecksum yes         # 存储和加载RDB校验完整性
stop-writes-on-bgsave-error yes     # 后台备份进程出错时,主进程停止写入.

AOF

AOF将Redis执行的每一条命令追加到硬盘文件中.然后在启动Redis时逐条执行AOF文件中的命令将数据载入内存.

Redis默认没有开启AOF, 需要以如下参数启用:

appendonly yes
no-appendfsync-on-rewrite  yes: # 正在导出RDB快照的过程中,停止同步AOF.

AOF重写

开启AOF后, Redis会将每一条有可能更改数据的命令写入AOF文件,这样就导致AOF文件越来越大,即使有可能内存中实际存储的数据并没多少. 因此Redis每当达到一定条件就自动重写AOF文件,这个条件可以在配置文件中设置:

auto-aof-rewrite-percentage 100 # 比起上次重写时的大小,AOF增长率100%时重写
auto-aof-rewrite-min-size 64mb  # AOF大小超过64M时重写

此外, 我们还可以使用BGREWRITEAOF命令手动执行AOF重写.


硬盘数据同步

执行AOF持久化时, 由于操作系统缓存机制, 数据并没有真正写入磁盘,而是进入了磁盘缓存, 默认情况下系统每30S执行一次同步操作, 将缓存内容真正写入磁盘, 如果在这30S的系统异常退出则会导致磁盘缓存数据丢失, 如果应用无法忍受这样的损失, 可通过appendfsync参数设置同步机制:

# appendfsync always    # 每次执行写入都执行同步
appendfsync everyse     # 每秒执行一次同步操作
# appendfsync no        # 不主动进行同步, 而是完全由操作系统执行.

集群

1. Replication

复制(replication)中,Redis的角色可以分为两类, Master:可以执行读/写操作,当写操作导致数据修改时会自动将数据同步给Slave; Slave:一般是只读的,并接受Master同步过来的数据(Slave自身也可以作为Master存在, 如图):

  • replication复制时序
    • Slave启动后向Master发送SYNC命令;Master收到后在后台保存RDB快照, 并将快照期间接收到的所有命令缓存.
    • 快照执行完, Master将快照文件与所有缓存的命令发送给Slave;
    • Slave接收并载入快照, 然后执行所有收到的缓存命令,这一过程称为复制初始化.
    • 复制初始化完成后,Master每接收到写命令就同步给Slave,从而保证主从数据一致.

  • 通过Redis的复制功能可以实现以下应用:
    • 读写分离:
      通过复制可实现读写分离, 以提高服务器的负载能力, 可以通过复制建立多个Slave节点, Master只进行写操作, 而由Slave负责读操作, 这种一主多从的结构很适合读多写少的场景.
    • Slave持久化
      持久化是一个相对耗时的操作, 为了提高性能, 可以通过复制功能建立一个/多个Slave, 并在Salve中启用持久化, Master禁用持久化. 当Master崩溃后:
      1. 在Slave使用SLAVEOF NO ONE命令将Slave提升成Master继续服务;
      2. 启用之前崩溃的Master, 然后使用SLAVEOF将其设置为新Master的Slave, 即可将数据同步回来.

注意: 当开启复制且Master关闭持久化时, Master崩溃后一定不能直接重启Master, 这是因为当Master重启后, 因为没有开启持久化, 所以Redis内的所有数据都会被清空, 这时Salve从Master接受数据, 所有的Slave也会被清空, 导致Slave持久化失去意义.

关于Redis复制的详细介绍以及配置方式可参考博客:Redis研究 -主从复制.


2. Sentinel

当Master遭遇异常中断服务后, 需要手动选择一个Slave升级为Master, 以使系统能够继续提供服务. 然而整个过程相对麻烦且需要人工介入, 难以实现自动化. 为此Redis提供了哨兵Sentinel.

Sentinel哨兵是Redis高可用性解决方案之一: 由一个/多个Sentinel实例组成的Sentinel系统可以监视任意多个Master以及下属Slave, 并在监控到Master进入下线状态时, 自动将其某个Slave提升为新的Master, 然后由新的Master代替已下线的Master继续处理命令请求.

  • 如图: 若此时Master:server1进入下线状态, 那么Slave: server2,server3,server4对Master的复制将被迫中止,并且Sentinel系统也会察觉到server1已下线, 当下线时长超过用户设定的下线时长时, Sentinel系统就会对server1执行故障转移操作:
    此处输入图片的描述
    • Sentinel会挑选server1下属的其中一台Slave, 将其提升为新Master;
    • 然后Sentinel向server1下属的所有Slave发送新的复制指令,让他们成为新Master的Salve, 当所有Salve都开始复制新Master时, 故障转移操作完成.
    • 另外, Sentinel还会继续监视已下线的server1, 并在他重新上线时, 将其设置为新Master的Slave.

关于Redis哨兵的详细介绍以及配置方式可参考博客:Redis Sentinel(哨兵):集群解决方案.


3. Cluster

Cluster是Redis提供的另一高可用性解决方案:Redis集群通过分片(sharding)来进行数据共享, 并提供复制故障转移功能.

一个 Redis 集群通常由多个节点组成, 最初每个节点都是相互独立的,要组建一个真正可工作的集群, 必须将各个独立的节点连接起来.连接各个节点的工作可以使用CLUSTER MEET命令完成:

CLUSTER MEET <ip> <port>

向一个节点发送CLUSTER MEET命令,可以使其与ip+port所指定的节点进行握手,当握手成功时, 就会将目标节点添加到当前节点所在的集群中.

  • 案例
    假设现在有三个独立的节点 127.0.0.1:7000 、 127.0.0.1:7001 、 127.0.0.1:7002:
    此处输入图片的描述
    • 通过向节点 7000 发送CLUSTER MEET 127.0.0.1 7001命令,可将节点7001添加到节点7000所在的集群中:
      此处输入图片的描述
    • 继续向节点7000发送CLUSTER MEET 127.0.0.1 7002命令,同样也可将节点7002也拉进来:
      此处输入图片的描述
    • 至此, 握手成功的三个节点处于同一个集群:
      此处输入图片的描述

关于Redis-Cluster的详细介绍以及更多配置方式可参考博客:redis-cluster研究和使用.


管理

1. 数据库密码

通过在配置文件中使用requirepass参数可为Redis设置密码:

requirepass œ∑´®†¥¨ˆøπ

这样客户端每次连接都需要发送密码,否则Redis拒绝执行客户端命令:

AUTH œ∑´®†¥¨ˆøπ

2. 重命名

Redis支持在配置文件中将命令重命名, 以保证只有自己的应用可以使用该命令:

rename-command FLUSHALL qwertyuiop

如果希望禁用某个命令,可将命令重命名为空字符串.


3. 工具

  • SLOWLOG
    当一条命令执行超过时间限制时,Redis会将其执行时间等信息加入耗时统计日志, 超时时间等可通过以下配置实现:
slowlog-log-slower-than 10000   # 超时限制(单位微秒)
slowlog-max-len 128             # 记录条数限制
  • MONITOR : 监控Redis执行的所有命令

    注意: MONITOR命令非常影响Redis性能, 一个客户端使用MONITOR会降低Redis将近一半的负载能力. Instagram团队开发了一个基于MONITOR命令的Redis查询分析工具redis-faina, 可根据MONITOR的监控结果分析出最常用的命令/访问最频繁的key等信息, 详细可参考博客:关于 Redis 的性能分析工具 Redis Faina.

  • 其他常用管理工具

TIME        # 系统时间戳与微秒数
DBSIZE      # 当前数据库的key数量
INFO        # Redis服务器信息
CONFIG GET  # 获取配置信息
CONFIG SET  # 设置配置信息
CONFIG REWRITE  # 把值写到配置文件
CONFIG RESTART  # 更新INFO命令信息
CLIENT LIST # 客户端列表
CLIENT KILL # 关闭某个客户端
CLIENT SETNAME  # 为客户端设置名字
CLIENT GETNAME  # 获取客户端名字
DEBUG OBJECT key    # 调试选项,查看一个key的信息
DEBUG SEGFAULT      # 模拟段错误,使服务器崩溃
OBJECT (refcount|encoding|idletime) key 

参考&拓展
高可用、开源的Redis缓存集群方案
Twemproxy——针对MemCached与Redis的代理
Redis 3.0正式版发布,正式支持Redis集群
Redis应用实践:小红书海量Redis存储之道
Redis内存优化实践
视频: Raft 教程
使用Redis作为时间序列数据库:原因及方法
Redis复制与可扩展集群搭建

posted @ 2016-07-26 10:08  挨踢人啊  阅读(299)  评论(0编辑  收藏  举报