第5章


持久层Redis数据库设计


  
  Redis(Remote Dictionary Server),即远程字典服务,Redis是一个key-value型的NoSQL数据库。
  Redis的官网地址为redis.io,从2010年3月15日起,Redis的开发工作由VMware主持,Redis的开发由Pivotal赞助。
5.1  Redis功能介绍
  与Memcached相比,Redis支持存储的value类型更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set有序集合)和hash(哈希类型)。这些数据类型都支持push/ pop、add/remove及取交集、并集和差集或更丰富的操作,而且这些操作都是原子性的。在此基础上,Redis支持各种不同方式的排序。与Memcached一样,为了保证效率,数据都是缓存在内存中。区别是Redis会周期性地把更新数据写入磁盘或写入追加的记录文件,并且在此基础上实现了Master-Slave(主从)同步。
  Redis 是一个高性能的key-value数据库,在很大程度补偿了Memcached这类key/value存储的不足,在部分场合可以对关系数据库起到很好的补充作用。Redis提供了Java、        C/C++、C#、PHP、JavaScript、Perl、Object-C、Python、Ruby、Erlang等客户端,使用非常           方便。?
  Redis支持主从同步,数据可以从主服务器向任意数量的从服务器上同步,这使得Redis可以执行单层树复制。存盘可以有意无意地对数据进行写操作。由于完全实现了发布/订阅机制,使得从数据库在任何地方同步树时,都可以订阅一个频道并接收主服务器完整的消息发布记录。同步对读取操作的可扩展性和数据冗余很有好处。
  Redis不是比较成熟的Memcached或者MySQL的替代品,是对于大型互联网应用在架构上很好的补充。现在越来越多的互联网平台纷纷在做基于Redis的架构改造,如京东、淘宝、当当、唯品会、美团、新浪微博等。
  下面简单公布一下源于新浪微博的Redis平台实际应用数据,供参考。
* 每天5000亿次读写操作。
* 超过18TB内存。
* 500多个服务器。
5.2  Redis应用场景
  Redis的应用场景基于Redis自身的以下特点。
* Redis是内存数据库,读写速度非常快,因此Redis适合做Cache(缓存),用于提高数据访问速度,减少关系数据库的压力。
* 关系数据库的Connection连接数非常有限(单机在几千以内),而Redis的单机并发连接可以达到几十万,因此适合在高并发环境缓解关系数据库的压力。
* Redis中没有事务(Transaction)控制,因此关键的交易数据读写仍然需要关系数据库,非关键数据的读写可以转到Redis中。
* Redis数据之间没有关联关系,数据结构简单,数据拓展容易,因此Redis适合做大集群部署,数据承载量非常庞大。
* Redis中的数据类型简单,无法做结构化查询(SQL),因此不适合有复杂关系的数据读写。
  Redis的实际应用非常广泛,下面简单列举几个常见的应用场景。
* 与关系数据库配合,做高速缓存。
* 全局计数器(如文章的阅读量、微博点赞数)。
* 利用zset类型存储排行榜数据。
* 利用list自然排序存储最新n个数据(新浪/Twitter用户消息列表)。
* HTTP Session数据存储。
* 分布式锁应用,防止超卖。
* 高并发环境全局ID获取(如生成唯一的订单编号)。
* 限流(int类型的incr方法,限制访问次数)。
* 利用hash结构做购物车存储。
* 消息队列(list提供了两个阻塞的弹出操作:blpop/brpop)。
* 防止缓存穿透和雪崩故障。
5.3  Redis下载与安装
  Redis目标安装环境CentOS7,安装步骤如下。
  (1)下载Redis安装包。
  从Redis官网下载redis-5.0.10.tar.gz,使用xftp工具上传到/usr/local目录下(如图5-1所示)。
  (2)进入虚拟机local目录,解压Redis安装包。
     

图5-1  上传Redis安装包
# cd  /usr/local
# tar  -xzvf  redis-5.0.10.tar.gz  	//把压缩包解压
# mv  redis-5.0.10 redis        		//修改解压后的目录名
# rm  redis-5.0.10.tar.gz        		//删除压缩包
     
  (3)安装GCC编译环境。
     
# yum install -y gcc-c++    
# gcc  -v              				//查看安装后的GCC版本
     
  (4)使用GCC编译Redis。
     
# cd  /usr/local/redis  			//进入安装目录
# make          					//编译
# make          					//再次编译确认
     
  (5)安装Redis。
     
# make install
     
  (6)检查Redis安装信息。
  进入cd /usr/local/bin目录,若显示如图5-2所示信息,表示Redis安装成功。
     

图5-2  Redis安装信息
  (7)修改Redis配置文件。
  进入Redis的安装目录cd /usr/local/redis,修改redis.conf的配置信息(如图5-3所示)。
     

图5-3  Redis配置文件
  修改Redis启动模式如下。
     
#vim redis.conf  
      daemonize  yes      	//作为后台服务启动
      protected-mode no   
      #bind 127.0.0.1     	//允许外部使用IP访问,注释掉该行   
   	 cat redis.conf       	//检查修改结果
     
  (8)启动Redis服务。
  直接执行下面的命令行,redis-server启动时需要访问redis.conf配置文件。
     
# /usr/local/bin/redis-server /usr/local/redis/redis.conf      
# ps -ef | grep redis  //redis启动后,检查redis进程(如图5-4所示)
     

图5-4  启动Redis服务器
  (9)客户端连接Redis服务器(如图5-5所示)。
     
# redis-cli -p 6379                			//本地连接方式
# redis-cli -h 192.168.25.128  -p 6379 	//远程连接方式
# ping 									//测试回应PING
# keys *  
# exit                						//客户端退出            
     

图5-5  客户端连接Redis服务器
  (10)使用Jedis访问测试。
     
# service firewalld status    	//查看防火墙状态   
# service firewalld stop     		//关闭所有端口的防火墙
5.4  案例:当当书城Redis实战
  当当书城功能需求和物理表设计参见4.8节。本节在4.8节的基础上,引入Redis数据库对当当书城进行性能优化和功能完善。
   5.4.1  Jedis连接Redis服务器
  Jedis是连接Redis服务器最常用的客户端,在Java环境导入包jedis-2.6.1.jar和commons-pool2-2.4.2.jar,即可使用Jedis客户端连接Redis服务器。
  参考代码类RedisUtil,使用连接池方式访问Redis,核心代码如下。
     
public class RedisUtil {             
      private static String ADDR = "192.168.25.128";  	//Redis服务器IP  
      private static int PORT = 6379;                		//Redis的端口号         
      private static JedisPool jedisPool = null;     
      /**          * 初始化Redis连接池         */
      static {
           try {
               JedisPoolConfig config = new JedisPoolConfig(); 
               config.setMaxTotal(80000);   	 	//可用连接实例的最大数量
               config.setMaxIdle(200);
               config.setMaxWaitMillis(10000);	//单位毫秒,默认值为-1,表示永不超时   
               config.setTestOnBorrow(true); 	//通过提前测试,确保连接可用
               jedisPool = new JedisPool(config, ADDR, PORT);
           } catch (Exception e) {
               Log.logger.error(e.getMessage(), e);
           }
      }     
      /**
       *  获取Jedis实例
       * @return
       */
      public synchronized static Jedis getJedis() throws Exception {
            Jedis jedis = null;
            if (jedisPool != null) {
                 jedis = jedisPool.getResource();
            }
            return jedis;       
}     
/**          * 释放Jedis资源          */
      public static void close(final Jedis jedis) {
             if (jedis != null) {
                  jedisPool.returnResource(jedis);
             }
      }
}
     
   5.4.2  图书缓存和排序
  参见4.8节的当当书城主页(如图4-38所示),主页显示的图书都是后台设置的推荐图书(如图4-40所示的表设计)。传统的实现方法是从MySQL数据库中直接提取主页推荐图书,但是当当书城用户量庞大,频繁的主页访问会给MySQL数据库带来巨大的压力。
  如果把图书数据缓存到Redis数据库中,主页推荐图书从Redis中提取,就会大大缓解MySQL数据库的压力。操作步骤如下。
  (1)定义监听器,在系统启动时装载所有图书数据。
     
@WebListener
public class LoadListener implements ServletContextListener {       
       private BookBiz bkBiz;    
       /**     *    监听器访问Spring容器,加载所有图书        	*/
       public void contextInitialized(ServletContextEvent sce)  { 
            ApplicationContext ctx = WebApplicationContextUtils
                        .getWebApplicationContext(sce.getServletContext());
            bkBiz = ctx.getBean(BookBiz.class);        
            try {
                 Jedis jedis = RedisUtil.getJedis();
                 loadAllBooks(jedis);
                 setMainSortBooks(jedis);
                 RedisUtil.close(jedis);
                 Log.logger.info("数据库连接OK,所有图书已经加载到Redis中");
                 bkBiz.logPath();         
            } catch (Exception e) {
                 Log.logger.error(e.getMessage(),e);            
            }        
       }       
       /**     * 把所有的图书信息都装载到Redis数据库中,存储类型为hash     */
       private void loadAllBooks(Jedis jedis) throws Exception{        
            List<Book> books = bkBiz.getAllBooks();    	//从数据库中读取所有图书   
            Map<String, String> map = new HashMap<String, String>();
            for(Book bk : books){    
                String bkString=JsonUtils.objectToJson(bk); //每本书生成一个json串
                map.put(bk.getIsbn(), bkString);
            }            
            jedis.hmset("allBookList",map);      		//把所有图书,存于hash中        
        }    
        /**
         * 主页推荐图书需要按照显示顺序号进行排序,因此存储类型为zset
         *  注意:zset中只存储排序号和ISBN,图像详情从hash中提取
        */
        private void setMainSortBooks(Jedis jedis)throws Exception{        
             List<MBook> books = bkBiz.getAllMainBook();      
             for(MBook bk : books){        
                  int  dno = bk.getDno();
                  jedis.zadd("bkMainZset", dno,bk.getIsbn());
             }        
       }
}
     
  (2)所有图书数据,在Redis中按照hash结构存储,主键是ISBN,值是json串。从MySQL数据库中提取的图书实体对象,需要转换成json串。实体与json串的互转方案,本书推荐使用jackson来实现,这也是Spring框架优先推荐的json转换方案。
     
public class JsonUtil {    
      private static final ObjectMapper MAPPER = new ObjectMapper();
      public static String objectToJson(Object data) throws Exception{
            String string = MAPPER.writeValueAsString(data);
            return string;        
      }
      public static <T> T jsonToObject(String jsonData, Class<T>beanType) throws 
            Exception{
            T t = MAPPER.readValue(jsonData, beanType);
            return t;        
      }
      public static <T> List<T> jsonToList(String jsonData,Class<T>beanType) throws 
            Exception{
            JavaType JavaType = MAPPER.getTypeFactory().constructParametricType
                               (List.class, beanType);
            List<T> list = MAPPER.readValue(jsonData, JavaType);
            return list;        
      }
}
     
  (3)持久层使用Mybatis从MySQL中提取主页图书,装载到Redis中。
     
<select id="getAllMainBook" resultType="MBook">
      select b.isbn,b.bname,b.price,b.pic,m.dno,m.rtime 
               from tbook b,TBookMain m where b.isbn=m.isbn order by m.dno desc
</select>
     
  (4)用户访问书城主页时,从Redis中提取主页推荐图书信息。
     
@Controller
public class BookAction {            
      @Autowired
      private BookRedis bkRedis;
      @RequestMapping("/main")
      public String getMainBook(Model model) throws Exception {
            List<Book> books = bkRedis.getAllMainBook(); //按顺序提取主页图书
            model.addAttribute("books",books);
            return "/jsp/main.jsp";
      }
}
     
  (5)从zset中提取所有主页图书的顺序,然后从hash中提取图书详情。
     
@Service
public class BookRedis {    
      /**     *  从Redis中读取所有主页推荐的图书         */
      public List<Book> getAllMainBook() throws Exception{        
            List<Book> bkList = new ArrayList<Book>();        
            Jedis jedis = RedisUtil.getJedis();            
            Set<String> isbns = jedis.zrevrange("bkMainZset", 0, -1);  
            for(String isbn : isbns){
                 String bkstring = jedis.hget("allBookList", isbn);
                 Book bk = JsonUtil.jsonToObject(bkstring, Book.class);
                 bkList.add(bk);            
            }        
            RedisUtil.close(jedis);                
            return bkList;        
      }        
}
     
   5.4.3  统计图书访问次数
  用户在当当书城,通过搜索或主页推荐找到图书后,单击进入图书详情页(如图5-6所示)。图书详情信息从Redis中提取,性能远高于访问MySQL。同时图书的每一次访问,都会被记录下来,这样热点图书排行就很容易被统计出来了(如图5-7所示)。
  图书访问次数统计的实现步骤如下。
  (1)普通用户浏览图书时,统计访问次数。
     
@Controller
public class BookAction {    
      @Autowired
      private BookRedis bkRedis;    
      @RequestMapping("/bookInfo")
      public String getBookInfo(@RequestParam String isbn,Modelmodel) throws 
      Exception{
            Book book = bkRedis.getBookInfo(isbn); 	//从Redis中读取图书详情
            model.addAttribute("bk", book);
            bkRedis.addBookPageView(isbn);  			//统计访问次数
            return "/jsp/BookDetail.jsp";
      }
}
     

图5-6  图书详情浏览

图5-7  图书访问次数统计
  (2)BookRedis逻辑类中的getBookInfo()从Redis中读取图书详情。
     
public Book getBookInfo(String isbn) throws Exception {
      Jedis jedis = RedisUtil.getJedis();            
      String bkstring = jedis.hget("allBookList", isbn);
      try {
            return JsonUtil.jsonToObject(bkstring, Book.class);    
      } finally {
             RedisUtil.close(jedis);
      }        
}
     
  (3)BookRedis逻辑类中的addBookPageView()方法,表示每次浏览[l1]图书时访问次数加1。
     
public void addBookPageView(String isbn) throws Exception{
      Jedis jedis = RedisUtil.getJedis();    
      jedis.zincrby("bkpv", 1, isbn); 		//zset中存储访问次数,每次给score加1
      RedisUtil.close(jedis);        
}
     
  (4)管理员登录后,进入页面访问次数统计页。
     
@GetMapping("/back/bkPageView")
public String getBookPageView(Model model) throws Exception{
      List<Book> books = bkRedis.getBookPageView();
      model.addAttribute("bkpv", books);        
      return "/back/bookPageView.jsp";
}
     
  (5)根据zset中的图书访问次数统计,显示排名在前100名的热门图书。
     
public List<Book> getBookPageView() throws Exception{
      List<Book> books = new ArrayList<Book>();        
      Jedis jedis = RedisUtil.getJedis();
      Set<Tuple> bkpv = jedis.zrevrangeWithScores("bkpv", 0, 100);
      for(Tuple tuple : bkpv){    
           String bkJson = jedis.hget("allBookList", tuple.getElement());            

           Book book = JsonUtil.jsonToObject(bkJson, Book.class);             
           book.setPageview((int)tuple.getScore());
           books.add(book);
      }        
      RedisUtil.close(jedis);        
      return  books;
}
     
  (6)视图层显示图书访问排名列表。
     
<table border="0" width=60% align="center">
      <tr>
           <td>书号ISBN</td>
           <td>图书名称</td>
           <td>价格
           <td>访问次数</td>
      </tr>
      <c:forEach var="bk" items="${bkpv}">
      <tr>
           <td>${bk.isbn}</td>
           <td>${bk.bname}</td>
           <td>${bk.price}
           <td>${bk.pageview}</td>
      </tr>
      </c:forEach>
</table>
     
   5.4.4  图书评论
  用户浏览器图书时,可以查看评论信息,已登录的用户还可以评论点赞。购买了图书的用户可以发表评论,如图5-8所示。
  图书评论信息是非关键的业务数据(无须事务控制),因此既可以放在MySQL数据库中,也可以放到Redis中。
  本节讲解基于Redis的图书评论代码实现方案,操作步骤如下。

图5-8  图书评论与评论点赞
  (1)从控制器BookAction中进入图书评论页。
     
@GetMapping("/pingLun")
public String pingLun(String isbn,Model model) throws Exception{
      List<BookComment> bcList = bkRedis.getBookComments(isbn);
      model.addAttribute("bcList", bcList);
      Book bk = bkRedis.getBookInfo(isbn);
      model.addAttribute("bk", bk);
      return "/jsp/bookComment.jsp";
}
     
  (2)从Redis中提取已有的当前图书评论信息。在Redis中,一本书有多条评论,每本书的所有评论信息使用list结构存储,list的命名规则为“pl-isbn值”。
     
public List<BookComment> getBookComments(String isbn) throws Exception{
      List<BookComment> comms = new ArrayList<BookComment>();        
      Jedis jedis = RedisUtil.getJedis();
      List<String>  bcList =  jedis.lrange("pl-"+isbn, 0, -1);
      for(String bc : bcList){
      BookComment bcJson = JsonUtil.jsonToObject(bc, BookComment.class);             
            comms.add(bcJson);
      }        
      RedisUtil.close(jedis);        
      return comms;
}    
     
  (3)已购买图书的用户,可以提交评论信息。
     
@PostMapping("/pingLun")
public String pingLun(String bkComm,String isbn,Model model,@SessionAttribute 
                         User user) throws Exception {
      bkRedis.addBookComment(isbn, user.getUname(), bkComm);
      List<BookComment> bcList = bkRedis.getBookComments(isbn);
      model.addAttribute("bcList", bcList);        
      Book bk = bkRedis.getBookInfo(isbn);
      model.addAttribute("bk", bk);
      return "/jsp/bookComment.jsp";
}