- redis和mysql数据一致性解决方案
- 如何防止表单重复提交
- SpringBoot自动化配置原理
- 乐观锁和悲观锁
- CAS
在高并发的业务场景下,数据库大多数情况都是用户并发访问最薄弱的环节。所以,就需要使用redis做一个缓冲操作,让请求先访问到redis,而不是直接访问MySQL等数据库。
这个业务场景,主要是解决读数据从Redis缓存,一般都是按照下图的流程来进行业务操作。
读写问题读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。
不管是先写MySQL数据库,再删除Redis缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。举一个例子:
-
如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。
-
如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。
因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。
如来解决?这里给出两个解决方案,先易后难,结合业务和技术代价选择使用。
在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。伪代码如下:
public void write( String key, Object data )
{
redis.delKey( key );
db.updateData( data );
Thread.sleep( 500 );//确保读的线程已经完成
redis.delKey( key );
}
public void write( String key, Object data )
{
redis.delKey( key );
db.updateData( data );
}
具体步骤就是:
- 先删除缓存
- 再写数据库
- 休眠500毫秒
- 再次删除缓存
那么,这个500毫秒怎么确定的,具体该休眠多久呢?
需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
当然这种策略还要考虑redis和数据库主从同步的耗时。最后的的写数据的休眠时间:则在读数据业务逻辑的耗时基础上,加几百ms即可。比如:休眠1秒。
设置缓存过期时间
从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。
该方案的弊端
结合双删策略+缓存超时设置,这样最差的情况就是在超时时间内数据存在不一致,而且又增加了写请求的耗时。
第二种方案-异步更新缓存(基于订阅binlog的同步机制)MySQL binlog增量订阅消费+消息队列+增量数据更新到redis
-
读Redis:热数据基本都在Redis
-
写MySQL:增删改都是操作MySQL
-
更新Redis数据:MySQL的数据操作binlog,来更新到Redis
(1)数据操作主要分为两大块:
- 一个是全量(将全部数据一次写入到redis)
- 一个是增量(实时更新)
这里说的是增量,指的是mysql的update、insert、delate变更数据。
(2)读取binlog后分析 ,利用消息队列,推送更新各台的redis缓存数据。
这样一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息推送至Redis,Redis再根据binlog中的记录,对Redis进行更新。
其实这种机制,很类似MySQL的主从备份机制,因为MySQL的主备也是通过binlog来实现的数据一致性。
这里可以结合使用canal(阿里的一款开源框架),通过该框架可以对MySQL的binlog进行订阅,而canal正是模仿了mysql的slave数据库的备份请求,使得Redis的数据更新达到了相同的效果。
当然,这里的消息推送工具你也可以采用别的第三方:kafka、rabbitMQ等来实现推送更新Redis!
如何防止表单重复提交 利用Redis+token防止表单重复提交(推荐)实现原理:
服务器返回表单页面时,会先生成一个token保存于redis,并把该token传给表单页面。当表单提交时会带上token,服务器拦截器Interceptor会拦截该请求,拦截器判断redis保存的token和表单提交token是否一致。若不一致或redis中的token为空或表单未携带token则不通过。
首次提交表单时redis的token与表单携带的token一致走正常流程,然后拦截器内会删除redis保存的token。当再次提交表单时由于redis的token为空则不通过。从而实现了防止表单重复提交。
流程图
①C -> /user/login – 产生一个token值,放入到session/redis中,并且将这个token返回到页面 --> login.jsp
②login.jsp - ajax提交 ----需要带上token----->登录拦截器[1. 拿到ajax请求过来的token和redis中的token进行比较是否一致,才会放行,删除redis保存的token] —>后台
SpringBoot自动配置化原理springboot中已经为我们注册了很多bean - 了解这些bean是怎么加入进去的.
-
手写A.java
-
@import
@Configuration @import(A.class) public class BootBeanConfig { }
-
配置类
@Configuration @import(MyAllBeans.class) public class BootBeanConfig { } -
MyAllBeans.java
public class MyAllBeans implements importSelector { @Override public String[] selectimports(Annotationmetadata annotationmetadata) { return new String[]{"tech.aistar.auto.RedisTp","tech.aistar.auto.MongodbTp"}; } } -
只要存在于RedisTp或者MongodbTp中出现的@Bean那些对象都将被spring容器进行管理了.
悲观锁悲观锁(Pessimistic Lock),顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。
悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
Java synchronized 就属于悲观锁的一种实现,每次线程要修改数据时都先获得锁,保证同一时刻只有一个线程能操作数据,其他线程则会被block。
比如:
public boolean updateStockRaw(Long productId){ ProductStock product = query("SELECT * FROM tb_product_stock WHERe product_id=#{productId} for update", productId); if (product.getNumber() > 0) { int updateCnt = update("UPDATE tb_product_stock SET stock=stock-1 WHERe product_id=#{productId}", productId); if(updateCnt > 0){ //更新库存成功 return true; } } return false; }多线程并发情况下,会存在超卖的可能。
CAS乐观锁
乐观锁(Optimistic Lock),顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在提交更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于读多写少的应用场景,这样可以提高吞吐量。
乐观锁:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。
乐观锁一般来说有以下2种方式:
- 使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。
- 使用时间戳(timestamp)。乐观锁定的第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。
比如:
public boolean updateStock(Long productId){ int updateCnt = 0; while (updateCnt == 0) { //在更新操作之前,先拿到要更新的对象. ProductStock product = query("SELECT * FROM tb_product_stock WHERe product_id=#{productId}", productId); if (product.getNumber() > 0) { //此处的number就是上面提到的版本号 - version //只要执行了update操作之后,版本号+1 updateCnt = update("UPDATE tb_product_stock SET stock=stock-1,number=number+1 WHERe product_id=#{productId} AND number=#{number}", productId, product.getNumber()); if(updateCnt > 0){ //更新库存成功 return true; } } else { //卖完啦 return false; } } return false; }使用乐观锁更新库存的时候不加锁,当提交更新时需要判断数据是否已经被修改(AND number=#{number}),只有在 number等于上一次查询到的number时 才提交更新。
注意 :UPDATE 语句的WHERe 条件字句上需要建索引
CAS:Compare and Swap,即比较再交换[自旋锁]。 - 乐观锁的一种实现方式
Jdk5增加了并发包java.util.concurrent.其下面的类使用CAS算法实现了区别于synchronized同步锁的一种乐观锁。JDK 5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁。
CAS算法理解
对CAS的理解,CAS是一种无锁算法,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
CAS比较与交换的伪代码可以表示为:
volitle int i = 0;
volitle可以保证可见性,但是不能保证原子性.
i++;//不是原子操作
//当线程执行i++的时候
//①将主存i的值拷贝一份放入到自己的线程栈[栈帧]中的本地缓存中
//②对i执行i+1操作[本地缓存中]
//③将本地缓存的改变刷新到主存中.
do{
备份旧数据;
基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))
注:t1,t2线程是同时更新同一变量56的值
因为t1和t2线程都同时去访问同一变量56,所以他们会把主内存的值完全拷贝一份到自己的工作内存空间,所以t1和t2线程的预期值都为56。
假设t1在与t2线程竞争中线程t1能去更新变量的值,而其他线程都失败。(失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次发起尝试)。t1线程去更新变量值改为57,然后写到内存中。此时对于t2来说,内存值变为了57,与预期值56不一致,就操作失败了(想改的值不再是原来的值)。
(上图通俗的解释是:CPU去更新一个值,但如果想改的值不再是原来的值,操作就失败,因为很明显,有其它操作先改变了这个值。)
就是指当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。容易看出 CAS 操作是基于共享数据不会被修改的假设,采用了类似于数据库的commit-retry 的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。



