Spring Data Redis 让 NoSQL 快如闪电(2)

【编者按】本文作者为 Xinyu Liu,文章的第一部分重点概述了 Redis 方方面面的特性。在第二部分,将介绍详细的用例。文章系国内 ITOM 管理平台 OneAPM 编译呈现。

把 Redis 当作数据库的用例

现在我们来看看在服务器端 Java 企业版系统中把 Redis 当作数据库的各种用法吧。无论用例的简繁,Redis 都能帮助用户优化性能、处理能力和延迟,让常规 Java 企业版技术栈望而却步。

1. 全局唯一增量计数器

我们先从一个相对简单的用例开始吧:一个增量计数器,可显示某网站受到多少次点击。Spring Data Redis 有两个适用于这一实用程序的类:RedisAtomicIntegerRedisAtomicLong。和 Java 并发包中的 AtomicIntegerAtomicLong 不同的是,这些 Spring 类能在多个 JVM 中发挥作用。

列表 3:全局唯一增量计数器

RedisAtomicLong counter = 
    new RedisAtomicLong("UNIQUE_COUNTER_NAME", redisTemplate.getConnectionFactory()); 
Long myCounter = counter.incrementAndGet();// return the incremented value

请注意整型溢出并谨记,在这两个类上进行操作需要付出相对较高的代价。

2. 全局悲观锁

时不时的,用户就得应对服务器集群的争用。假设你从一个服务器集群运行一个预定作业。在没有全局锁的情况下,集群中的节点会发起冗余作业实例。假设某个聊天室分区可容纳 50 人。如果聊天室已满,就需要创建新的聊天室实例来容纳另外 50 人。

如果检测到聊天室已满但没有全局锁,集群中的各个节点就会创建自有的聊天室实例,为整个系统带来不可预知的因素。列表 4 介绍了应当如何充分利用 SETNXSET if Not eXists:如果不存在,则设置)这一 Redis 命令来执行全局悲观锁。

列表4:全局悲观锁

public String aquirePessimisticLockWithTimeout(String lockName,            int acquireTimeout, int lockTimeout) {        
  
  if (StringUtils.isBlank(lockName) || lockTimeout <= 0)            
      return null;        
      final String lockKey = lockName;
        String identifier = UUID.randomUUID().toString(); 
        Calendar atoCal = Calendar.getInstance();
        atoCal.add(Calendar.SECOND, acquireTimeout);
        Date atoTime = atoCal.getTime();        
        
        while (true) {            
           // try to acquire the lock            
           if (redisTemplate.execute(new RedisCallback<Boolean>() {                @Override                
           public Boolean doInRedis(RedisConnection connection)                        throws DataAccessException {                    
           return connection.setNX(
redisTemplate.getStringSerializer().serialize(lockKey), redisTemplate.getStringSerializer().serialize(identifier));
                }
            })) {   // successfully acquired the lock, set expiration of the lock
             redisTemplate.execute(new RedisCallback<Boolean>() {                      @Override                    
             public Boolean doInRedis(RedisConnection connection)                            throws DataAccessException {                        
              return connection.expire(redisTemplate
                                .getStringSerializer().serialize(lockKey),
                                lockTimeout);
                    }
                });                
                return identifier;
            } else { // fail to acquire the lock                
            // set expiration of the lock in case ttl is not set yet.                if (null == redisTemplate.execute(new RedisCallback<Long>() {                    @Override                    
            public Long 
      doInRedis(RedisConnection connection)                            
         throws DataAccessException 
         {                        
              return connection.ttl(redisTemplate
                                .getStringSerializer().serialize(lockKey));
                    }
                })) {                    // set expiration of the lock
                    redisTemplate.execute(new RedisCallback<Boolean>() 
                    {                        
                    @Override                        
                    public Boolean 
                    
           doInRedis(RedisConnection connection)                                        throws DataAccessException {                            
           return connection.expire(redisTemplate
                                .getStringSerializer().serialize(lockKey),
                                    lockTimeout);
                        }
                    }); 
}                if (acquireTimeout < 0) // no wait                    
                 return null;                
                 else {                    
                     try {
                        Thread.sleep(100l); // wait 100 milliseconds before retry
                    } catch (InterruptedException ex) {
                    }
                }                if (new Date().after(atoTime))                    break;
            }
        }        return null;
    }    
    
    
    public void 
releasePessimisticLockWithTimeout(String lockName, String identifier) {        if (StringUtils.isBlank(lockName) || StringUtils.isBlank(identifier))            return;        

     final String lockKey = lockName;

        redisTemplate.execute(new RedisCallback<Void>() {                          @Override                    
        public Void doInRedis(RedisConnection connection)                            throws DataAccessException {                        
        byte[] ctn = connection.get(redisTemplate
                                .getStringSerializer().serialize(lockKey));                        if(ctn!=null && identifier.equals(redisTemplate.getStringSerializer().deserialize(ctn)))
                            connection.del(redisTemplate.getStringSerializer().serialize(lockKey));                        return null;
                    }
                });
    }

如果使用关系数据库,一旦最先生成锁的程序意外退出,锁就可能永远得不到释放。Redis 的 EXPIRE 设置可确保在任何情况下释放锁。

3. 位屏蔽(Bit Mask)

假设 web 客户端需要轮询一台 web 服务器,针对某个数据库中的多个表查询客户指定更新内容。如果盲目地查询所有相应的表以寻找潜在更新,成本较高。为了避免这一做法,可以尝试在 Redis 中给每个客户端保存一个整型作为脏指标,整型的每个数位表示一个表。该表中存在客户所需更新时,设置数位。轮询期间,不会触发对表的查询,除非设置了相应数位。就获取并将这样的位屏蔽设置为 STRING 而言,Redis 非常高效。

4. 排行榜(Leaderboard)

Redis 的 ZSET 数据结构为游戏玩家排行榜提供了简洁的解决方案。ZSET 的工作方式有些类似于 Java 中的 PriorityQueue,各个对象均为经过排序的数据结构,井井有条。可以按照分数排出游戏玩家在排行榜上的位置。Redis 的 ZSET 定义了一份内容丰富的命令列表,支持灵活有效的查询。例如,ZRANGE(包括 ZREVRANGE)可返回有序集内的指定范围要素。

你可以使用这一命令列出排行榜前 100 名玩家。ZRANGEBYSCORE 返回指定分数范围内的要素(例如列出得分为 1000 至 2000 之间的玩家),ZRNK 则返回有序集内的要素的排名,诸如此类。

5. 布隆(Bloom)过滤器

[布隆过滤器]11 是一种空间利用率较高的概率数据结构,用来测试某元素是否某个集的一员。可能会出现误报匹配,但不会漏报。查询可返回“可能在集内”或“肯定不在集内”。

就在线服务和离线服务包括大数据分析等方面,布隆过滤器数据结构都能派上很多用场。Facebook 利用布隆过滤器进行输入提示搜索,为用户输入的查询提取朋友和朋友的朋友。Apache HBase 则利用布隆过滤器过滤掉不包含特殊行或列的 HFile 块磁盘读取,使读取速度得到明显提升。Bitly 用布隆过滤器来避免将用户重定向到恶意网站,而 Quara 则在订阅后端执行了一个切分的布隆过滤器,用来过滤掉之前查看过的内容。在我自己的项目里,我用布隆过滤器追踪用户对各个主题的投票情况。

借助出色的速度和处理能力,Redis 极好地融合了布隆过滤器。搜索 GitHub,就能发现很多 Redis 布隆过滤器项目,其中一些还支持可调谐精度。

6. 高效的全局通知:发布/订阅渠道

Redis 发布/订阅渠道的工作方式类似于一个扇出消息传递系统,或 JMS 语义中的一个主题。JMS 主题和 Redis 发布/订阅渠道的一个区别是,通过 Redis 发布的消息并不持久。消息被推送给所有相连的客户端后,Redis 上就会删除这一消息。换句话说,订阅者必须一直在线才能接收新消息。Redis 发布/订阅渠道的典型用例包括实时配置分布、简单的聊天服务器等。

在 web 服务器集群中,每个节点都可以是 Redis 发布/订阅渠道的一个订阅者。发布到渠道上的消息也会被即时推送到所有相连节点。这一消息可以是某种配置更改,也可以是针对所有在线用户的全局通知。和恒定轮询相比,这种推送沟通模式显然极为高效。

Redis 性能优化

Redis 非常强大,但也可以从整体上和根据特定编程场景做出进一步优化。可以考虑以下技巧。

存活时间

所有 Redis 数据结构都具备存活时间 (TTL) 属性。当你设置这一属性时,数据结构会在过期后自动删除。充分利用这一功能,可以让 Redis 保持较低的内存损耗。

管道技术

在一条请求中向 Redis 发送多个命令,这种方法叫做管道技术。这一技术节省了网络往返的成本,这一点非常重要,因为网络延迟可能比 Redis 延迟要高上好几个量级。但这里存在一个陷阱:管道中的 Redis 命令列表必须预先确定,并且应当彼此独立。如果一个命令的参数是由先前命令的结果计算得出,管道技术就不起作用。列表 5 给出了 Redis 管道技术的一个示例。

列表 5:管道技术

@Override
public List<LeaderboardEntry> fetchLeaderboard(String key, String... playerIds) {    
   final List<LeaderboardEntry> entries = new ArrayList<>();
    redisTemplate.executePipelined(new RedisCallback<Object>() {    // enable Redis Pipeline        
    @Override 
        public Object doInRedis(RedisConnection connection) throws DataAccessException { 
            for(String playerId : playerIds) {
                Long rank = connection.zRevRank(key.getBytes(), playerId.getBytes());
                Double score = connection.zScore(key.getBytes(), playerId.getBytes());
                LeaderboardEntry entry = new LeaderboardEntry(playerId, 
                score!=null?score.intValue():-1, rank!=null?rank.intValue():-1);
                entries.add(entry);
            }        
            return null; 
        }
    }); 
    return entries; 
}

副本集和切分

Redis 支持主从副本配置。和 MongoDB 一样,副本集也是不对称的,因为从节点是只读的,以便共享读取工作量。我在文章开头提到过,也可以执行切分来横向扩展 Redis 的处理能力和存储容量。事实上,Redis 非常强大,据亚马逊公司的内部基准显示,类型 r3.4xlarge 的一个 EC2 实例每秒可轻松处理 100000 次请求。传说还有把每秒 700000 次请求作为基准的。对于中小型应用程序,通常无需考虑 Redis 切分。(请参见这篇非常出色的文章《运行中的 Redis》,进一步了解 Redis 的性能优化和切分。)

Redis 中的事务

Redis 并不像关系数据库管理系统那样能支持全面的 ACID 事务,但其自有的事务也非常有效。从本质上来说,Redis 事务是管道、乐观锁、确定提交和回滚的结合。其思想是执行一个管道中的一个命令列表,然后观察某一关键记录的潜在更新(乐观锁)。根据所观察的记录是否会被另一个进程更新,该命令列表或整体确定提交,或完全回滚。

下面以某个拍卖网站上的卖方库存为例。买方试图从卖方处购买某件商品时,你负责观察 Redis 事务内的卖方库存变化。同时,你要从同一个库存中删除此商品。事务关闭前,如果库存被一个以上进程触及(例如,如果两个买方同时购买了同一件商品),事务将回滚,否则事务会确定提交。回滚后可开始重试。

Spring Data Redis 中的事务陷阱

我在 Spring 的 RedisTemplateredisTemplate.setEnableTransactionSupport(true); 中启用 Redis 事务时得到一个惨痛的教训:Redis 会在运行几天后开始返回垃圾数据,导致数据严重损坏。* 上也报道了类似情况。

在运行一个 monitor 命令后,我的团队发现,在进行 Redis 操作或 RedisCallback 后,Spring 并没有自动关闭 Redis 连接,而事实上它是应该关闭的。如果再次使用未关闭的连接,可能会从意想不到的 Redis 密钥返回垃圾数据。有意思的是,如果在 RedisTemplate 中把事务支持设为 false,这一问题就不会出现了。

我们发现,我们可以先在 Spring 语境里配置一个 PlatformTransactionManager(例如 DataSourceTransactionManager),然后再用 @Transactional 注释来声明 Redis 事务的范围,让 Spring 自动关闭 Redis 连接。

根据这一经验,我们相信,在 Spring 语境里配置两个单独的 RedisTemplate 是很好的做法:其中一个 RedisTemplates 的事务设为 false,用于大多数 Redis 操作,另一个 RedisTemplates 的事务已激活,仅用于 Redis 事务。当然必须要声明 PlatformTransactionManager@Transactional,以防返回垃圾数值。

另外,我们还发现了 Redis 事务和关系数据库事务(在本例中,即 JDBC)相结合的不利之处。混合型事务的表现和预想的不太一样。

结论

我希望通过这篇文章向其他 Java 企业开发师介绍 Redis 的强大之处,尤其是将 Redis 用作远程数据缓存和用于易挥发数据时。在这里我介绍了 Redis 的六个有效用例,分享了一些性能优化技巧,还说明了我的 Glu Mobile 团队怎样解决了 Spring Data Redis 事务配置不当造成的垃圾数据问题。我希望这篇文章能够激发你对 Redis NoSQL 的好奇心,让你能够受到启发,在自己的 Java 企业版系统里创造出一番天地。

本文转自 OneAPM 官方博客

原文地址:http://www.javaworld.com/article/3062899/big-data/lightning-fast-nosql-with-spring-data-redis.html?page=2

上一篇:源码安装apache实例


下一篇:Spring Data Redis 让 NoSQL 快如闪电 (1)