第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"; }