Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进

时间:2023-02-09 17:29:36

转载:http://blog.csdn.net/stubborn_cow/article/details/50586990

转载:http://blog.csdn.net/liubenlong007/article/details/53690312
转载:http://blog.csdn.net/donggang1992/article/details/50981341

转载:http://blog.csdn.net/lc0817/article/details/52089473

转载:http://blog.csdn.net/tb3039450/article/details/53928351

转载:http://jingyan.baidu.com/article/09ea3ede1dd0f0c0aede3938.html


一 业务场景分类

在数据库应用中有可能存在这样的场景,在100条数据中常被读写的数据只有20条,此时这20条数据对整个数据库而言就是热点数据,对于热点数据的缓存和处理有助于提升系统的性能,Redis和Mysql的结合就是为了能够优化热点数据的读写,以提升系统的健壮性和性能。

Memecache?Redis?MongoDB

三者都可用于数据库的缓存,但在业务上有所区分:

  • Memcached:内存型数据库,无持久化功能,掉电即失,可靠性差,用于动态系统中减轻数据库负载,提升性能;做缓存,适合多读少写大数据量的情况(如人人网大量查询用户信息、好友信息、文章信息等)。
  • Redis:内存型数据库,有持久化功能,具备分布式特性,可靠性高,适用于对读写效率要求都很高,数据处理业务复杂和对安全性要求较高的系统(如新浪微博的计数和微博发布部分系统,对数据安全性、读写要求都很高)。
  • MongoDB: Mongodb是文档型的非关系型数据库,其优势在于查询功能比较强大,能存储海量数据,主要解决海量数据的访问效率问题。 

二 Redis数据淘汰策略

问题来了,如果:“MySQL里有2000w数据,redis中只存20w的数据,如何保证redis中的数据都是热点数据?”

在 redis 中,允许用户设置最大使用内存大小通过配置redis.conf中的maxmemory这个值来开启内存淘汰功能,在内存限定的情况下是很有用的。设置最大内存大小可以保证redis对外提供稳健服务。
redis 内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略。redis 提供 6种数据淘汰策略通过maxmemory-policy设置策略:

  1. volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  4. allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
  6. no-enviction(驱逐):禁止驱逐数据
redis 确定驱逐某个键值对后,会删除这个数据并将这个数据变更消息发布到本地(AOF 持久化)和从机(主从连接)。

三 数据一致性问题

Mysql与Redis的结合中,数据的一致性是必须要解决的问题。问题的产生描述如下:

    Object stuObj = new Object();

    public Stu getStuFromCache(String key){
        Stu stu = (Stu) redis.get(key);
        if(stu == null){
            synchronized (stuObj) {
                stu = (Stu) redis.get(key);
                if(stu == null){
                    Stu stuDb = db.query();
                    redis.set(key, stuDb);
                }
            }
        }

        return stu;
    }

上面加锁是为了防止过多的查询走到数据库层,写数据库伪代码:

public void setStu(){
    redis.del(key);
    db.write(obj);
}

不管是先写库,再删除缓存;还是先删缓存,再写库,都有可能出现 数据不一致的情况
因为写和读是并发的,没法保证顺序,如果删了缓存,还没有来得及写库,另一个线程就来读取,发现缓存为空,则去数据库中读取数据 写入缓存,此时缓存中为脏数据。如果先写了库,再删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。 
如果是redis集群,或者主从模式, 写主读从,由于 redis复制存在一定的时间延迟,也有可能导致数据不一致。

优化思路:

1. 双删 + 超时
写库前后都进行redis.del(key)操作,并且设定合理的超时时间。这样最差的情况是在超时时间内存在不一致,当然这种情况极其少见,可能的原因就是服务宕机。此种情况可以满足绝大多数需求。 
当然这种策略要考虑redis和数据库主从同步的耗时,所以在第二次删除前最好休眠一定时间,比如500毫秒,这样毫无疑问又增加了写请求的耗时。
2. 异步淘汰缓存

通过读取binlog的方式,异步淘汰缓存。 

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进


四 Redis主从机制和哨兵机制 

主从机制

Redis的复制功能是支持多个数据库之间的数据同步。一类是主数据库(master)一类是从数据库(slave),主数据库可以进行读写操作,当发生写操作的时候自动将数据同步到从数据库,而从数据库一般是只读的,并接收主数据库同步过来的数据,一个主数据库可以有多个从数据库,而一个从数据库只能有一个主数据库。
通过redis的复制功能可以很好的实现数据库的读写分离,提高服务器的负载能力。主数据库主要进行写操作,而从数据库负责读操作

主从复制过程

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进

过程:
1:当一个从数据库启动时,会向主数据库发送sync命令,
2:主数据库接收到sync命令后会开始在后台保存快照(执行rdb操作),并将保存期间接收到的命令缓存起来
3:当快照完成后,redis会将快照文件和所有缓存的命令发送给从数据库。
4:从数据库收到后,会载入快照文件并执行收到的缓存的命令。

当一个从数据库启动时,会向主数据库发送SYNC命令,主数据库收到命令后会开始在后台保存快照(即RDB持久化过程),并将保存快照期间接收到的命令缓存起来。当快照完成后,Redis会将快照文件和缓存的命令发给从数据库,从数据库收到数据后,会载入快照文件并执行缓存的命令。以上过程称为复制初始化

复制初始化之结束后,主数据库每收到写命令时就会将命令同步给从数据库,从而保证主从数据库数据一致,这一过程称为复制同步阶段复制同步阶段贯穿整个主从同步过程的始终,直到主从关系终止为止。在复制过程中,即使关闭了RDB方式的持久化(删除所有save参数),依旧会执行快照操作。

乐观复制
Redis采用了乐观复制的策略。容忍在一定时间内主从数据库的内容是不同的,但是两者的数据最终会保持一致。具体来说,Redis主从数据库之间的复制数据的过程本身是异步的,这意味着,主数据库执行完客户端的写请求后会立即将命令在主数据库的执行结果返回给客户端,而不会等待从数据库收到该命令后再返回给客户端。这一特性保证了复制后主从数据库的性能不会受到影响,但另一方面也会产生一个主从数据库数据不一致的时间窗口,当主数据库执行一条写命令之后,主数据库的数据已经发生变动,然而在主数据库将该命令传送给从数据库之前,如果两个数据库之间的连接断开了,此时二者间的数据就不一致了。

哨兵机制

Redis的sentinel系统用于管理多个redis服务器,该系统主要执行三个任务:监控、提醒、自动故障转移
1、监控(Monitoring): Redis Sentinel实时监控主服务器和从服务器运行状态,并且实现自动切换
2、提醒(Notification):当被监控的某个 Redis 服务器出现问题时, Redis Sentinel 可以向系统管理员发送通知, 也可以通过 API 向其他程序发送通知。
3、自动故障转移(Automatic failover): 当一个主服务器不能正常工作时,Redis Sentinel 可以将一个从服务器升级为主服务器, 并对其他从服务器进行配置,让它们使用新的主服务器。当应用程序连接Redis 服务器时, Redis Sentinel会告之新的主服务器地址和端口。
注意:在使用sentinel监控主从节点的时候,从节点需要是使用动态方式配置的,如果直接修改配置文件,后期sentinel实现故障转移的时候会出问题。

Sentinel工作方式: 
1):每个Sentinel以每秒钟一次的频率向它所知的Master,Slave以及其他 Sentinel 实例发送一个 PING 命令 
2):如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被 Sentinel 标记为主观下线。 
3):如果一个Master被标记为主观下线,则正在监视这个Master的所有 Sentinel 要以每秒一次的频率确认Master的确进入了主观下线状态。 
4):当有足够数量的 Sentinel(大于等于配置文件指定的值)在指定的时间范围内确认Master的确进入了主观下线状态, 则Master会被标记为客观下线 
5):在一般情况下, 每个 Sentinel 会以每 10 秒一次的频率向它已知的所有Master,Slave发送 INFO 命令 
6):当Master被 Sentinel 标记为客观下线时,Sentinel 向下线的 Master 的所有 Slave 发送 INFO 命令的频率会从 10 秒一次改为每秒一次 
7):若没有足够数量的 Sentinel 同意 Master 已经下线, Master 的客观下线状态就会被移除。若 Master 重新向 Sentinel 的 PING 命令返回有效回复, Master 的主观下线状态就会被移除。

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进

客户端可以将 Sentinel 看作是一个只提供了订阅功能的 Redis 服务器,你不可以使用 PUBLISH 命令向这个服务器发送信息,但你可以用 SUBSCRIBE 命令或者 PSUBSCRIBE 命令, 通过订阅给定的频道来获取相应的事件提醒。 
一个频道能够接收和这个频道的名字相同的事件。 比如说, 名为 +sdown 的频道就可以接收所有实例进入主观下线(SDOWN)状态的事件。

五 缓存更新的四种设计模式

更新缓存的的Design Pattern有四种:Cache aside, Read through, Write through, Write behind caching,我们下面一一来看一下这四种Pattern。

Cache Aside Pattern

这是最常用最常用的pattern了。其具体逻辑如下:

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。
 注意,我们的更新是 先更新数据库,成功后, 让缓存失效。一个是查询操作,一个是更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时, 缓存依然有效,所以,并发的查询操作拿的是 没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。而后续的查询操作不会一直都在取老的数据。

Read/Write Through Pattern

我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

Read Through

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的(笔者:在实现中应当把更新缓存的操作封装到数据库的查询语句中,但是由于一般系统实现都是数据库代码在前,缓存多是后期遇到性能瓶颈之后添加的附加手段,为了防止新增代码对老代码的侵入和破坏,一般很少使用这种模式,在初始就设计良好的系统中可以如此设计。)。

Write Through

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)。

Write Behind Caching Pattern

Write Behind 又叫 Write Back。一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?是的,你看基础这玩意全都是相通的。所以,基础很重要,我已经不是一次说过基础很重要这事了。
Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。
另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。

六 Redis与Mysql结合方案演进 

1. 程序同时写Redis和MySQL

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进

这是一种相对直观而且简单的方法,客户端先从Redis读取数据,如果没有查询到;便从mysql查询数据,将查询到的内容放到Redis中。对于写操作,先对mysql进行写,写成功对Redis进行写。这种情况下需要实现如上的全部流程。其实现可以遵照如下流程。

1.编写Mysql连接类用于管理连接,并提供Mysql操作方法。

2.编写Jedis连接类用于管理i连接,并提供Redis操作方法。

3. 封装实现函数(下例以登陆功能为例):

public class Main {
         Mysql mysql=new Mysql();
         Redis redis=new Redis();
         ResultSet rs=null;
        
         //模拟登陆缓存
         @Test
         public void redisLogin() throws SQLException{
                 //正常业务的ID是通过UI的request.getParamenter()获取
                 String id="9028935b527d22cc01527d235aea0142";
                 String sql="select * from user where id_='"+id+"'";
                 String username;
                 if(redis.hexists("user_"+id, "username_")){
                          username=redis.hget("user_"+id, "username_");
                          System.out.println("Welcome Redis! User "+username+" login success");
                 }else{
                          rs=mysql.conn.createStatement().executeQuery(sql);
                          if(rs.next()==false){
                                   System.out.println("Mysql no register, Please register first");
                          }else{
                                   username=rs.getString("username_");
                                   System.out.println("Welcome Mysql ! User "+username+" login success");
                                   redis.hset("user_"+id, "username_", username);
                                   //30分钟未操作就过期
                                   redis.expire("user_"+id, 1800);
                          }
                 }
                
         }
}

方案存在的问题:无法应对大数据量的穿透、雪崩和热点Key问题。



2. 程序写MySQL, 使用Gearman调用MySQL的UDF,完成对Redis的写

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进

Gearman是一个开源的Map/Reduce分布式计算框架,具有丰富的client sdk,而且它支持MySQL UDF。

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进

Gearman调用流程

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进

MySQL - Redis配合使用方案如下:

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进

首先我们以MySQL数据为主,将insert/update/delete交给MySQL,而select交给redis;当有数据发生变化时,通过MySQL Trigger实时异步调用Gearman的UDF提交一个job给Job Server,当job执行的时候会去更新redis;从而保证redis与MySQL中的数据是同步的。

Gearman一般用PHP语言开发较多,网上能找到很多例子,使用Java语言开发例子较少。UDF的开发多用C++进行,German Worker作为一个桥梁提供给客户端程序使用。例子见《【Java】利用Gearman进行Mysql到Redis的复制》

方案存在的问题:使用非官方第三方插件带来的额外风险和学习成本。


3. 程序写MySQL, 解析binlog,数据放入队列写Redis

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进


MySQL Server 有四种类型的日志——Error Log、General Query Log、Binary Log 和 Slow Query Log。

第一个是错误日志,记录 mysqld 的一些错误。第二个是一般查询日志,记录 mysqld 正在做的事情,比如客户端的连接和断开、来自客户端每条 Sql Statement 记录信息;如果你想准确知道客户端到底传了什么玩意儿给服务端,这个日志就非常管用了,不过它非常影响性能。第四个是慢查询日志,记录一些查询比较慢的 SQL 语句——这种日志非常常用,主要是给开发者调优用的

剩下的第三种就是 Binlog 了,包含了一些事件,这些事件描述了数据库的改动,如建表、数据改动等,也包括一些潜在改动,比如 DELETE FROM ran WHERE bing = luan,然而一条数据都没被删掉的这种情况。除非使用 Row-based logging,否则会包含所有改动数据的 SQL Statement。那么 Binlog 就有了两个重要的用途——复制和恢复。比如主从表的复制,和备份恢复什么的。

Canal是阿里的开源项目,其原理主要是基于数据库的日志解析,获取增量变更进行同步,由此衍生出了增量订阅&消费的业务。Canal的原理是模拟Slave向Master发送请求,Canal解析binlog,但不将解析结果持久化,而是保存在内存中,每次有客户端读取一次消息,就删除该消息。这里所说的客户端,就需要我们写一个连接Canal的程序,持续从Canal获取数据。具体实现见《利用Canal完成Mysql数据同步Redis


方案存在的问题:实现逻辑复杂,需要实现双次写入流程。(不过只要有方案,复杂都不是个事,总会有人去做的不是)


4. 程序写Redis,并将写放入MQ写MySQL

Java Jedis操作Redis示例(四)——Redis和Mysql的结合方案演进

在这种方案下的缓存更新设计模式可参考“Write Behind Caching Pattern”,读写均在缓存上进行,并通过MQ同步到MySQL中。从MQ持久化到MySQL的实现可参考《ActiveMQ的消息持久化到Mysql数据库


方案存在的问题:重量级MQ增加系统复杂度,需要额外代码维护MQ的一致性。数据存在丢失风险,适合于将数据库作为最终持久化方法、数据可以少量丢失的场景。