在实际业务中,经常碰见数据库和缓存中数据不一致的问题,缓存作为抵挡前端访问洪峰的工具,用的好的话可以大大减轻服务端压力,但是在一些场景下,如果没有控制好很容易造成数据库和缓存的数据不一致性,尤其是在并发环境下,这种情况出现的概率会大大的增加,为什么会出现这个问题呢?我们来简单分析一下。
1、页面发起一个请求,请求更新某个商品的库存,正常的流程是,如果没有缓存,直接更新数据库即可,如果有缓存,先删除该商品在redis中的缓存,然后再去更新数据库,同时可能还把更新后的库存数据同步到缓存中,这是正常的流程,也是基本上保证数据库和缓存数据一致性最基本的方式;
2、假如还是第一个场景的描述,这时候因为某种原因,比如网络中断一会儿,或者数据库连接异常导致在删除缓存的那一瞬间,正好有一个读取商品库存的请求发过来,而且正好这时候环境恢复正常,由于这两次操作可能是在分布式的环境下进行的,各自归属不同的事物互不影响,那么这时候问题就来了,读请求读到的数据还是老数据,而实际上应该是读到上一个请求更新后的库存数据,这样就造成了数据的不一致性,这样的场景在高并发环境下应该很容易模拟出来;
常见的解决思路在上面的描述中已经提到过一种,即先删除缓存在更新数据库,但这是在普通的环境下,但是在场景2中该如何解决呢?
我们可以设想,假如说有这样一种方式,对相同的数据进行操作,比如某商品的productId,或者订单orderId,我们是否可以让前一个写请求没有完成的情况下,后面的读请求被阻塞住那么一段时间,等待前面的写操作执行完毕后再进行读操作呢?
理论上来说是可行的,我们可以通过某种方式,比如阻塞队列,让相同的数据进入相同的队列,阻塞队列就有很好的顺序执行保障机制,保证入队的数据顺序执行,这样一来,是不是就可以达到目的呢?
说白了,就是前端发来一个的请求,若是写请求,将这个请求入队,然后从队列中取出请求执行写的业务逻辑操作,如果是读请求而且是相同的数据,我们通过自定义的想过路由算法路由到相同的队列中,通过设定一定的等待时间来达到让这两个请求由并行操作变成串行操作,这不就达到目的了吗?
按照这个思路,我们来解决下面的业务需求。
第一个请求,更新数据库的商品缓存,这时候,又一个请求过来,要读取这个商品的库存
通过以上的分析思路,我们将解决的代码思路整理如下,
1、初始化一个监听器,在程序启动的时候将针对不同请求的队列全部创建出来;
2、封装两种不同的请求对象,一种是更新缓存,一种是读取数据并刷新到缓存;
3、处理两种不同请求的异步路由service;
4、两种请求的controller;
5、读请求去重优化;
6、空数据读请求过滤优化
根据以上思路,我们来整合一下这个过程,
1、项目结构,
2、pom依赖文件,主要是springboot的相关依赖,
org.springframework.boot
spring-boot-starter-parent
2.0.1.RELEASE
UTF-8
UTF-8
1.8
org.springframework.boot
spring-boot-starter-freemarker
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-jdbc
org.springframework.boot
spring-boot-starter-test
test
org.mybatis.spring.boot
mybatis-spring-boot-starter
1.3.2
org.springframework.boot
spring-boot-starter-cache
org.springframework.boot
spring-boot-starter-data-redis
org.springframework.boot
spring-boot-starter-amqp
junit
junit
4.12
org.apache.httpcomponents
httpclient
org.apache.httpcomponents
httpcore
org.apache.httpcomponents
httpmime
com.alibaba
fastjson
1.2.7
redis.clients
jedis
2.9.0
mysql
mysql-connector-java
runtime
org.apache.tomcat
tomcat-jdbc
org.springframework.boot
spring-boot-maven-plugin
3、整合mybatis以及数据库的连接,这里采用的是程序加载bean的方式配置,
/**
* 整合mybatis以及数据库连接配置
* @author asus
*
*/
@Component
public class DataSourceConfig {
@Bean
@ConfigurationProperties(prefix="spring.datasource")
public DataSource dataSource(){
return new DataSource();
}
@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception{
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSource());
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
sqlSessionFactoryBean.setMapperLocations(resolver.getResources("classpath:/mybatis/*.xml"));
return sqlSessionFactoryBean.getObject();
}
@Bean
public PlatformTransactionManager transactionManager(){
return new DataSourceTransactionManager(dataSource());
}
}
数据库配置信息:application.properties
server.port=8085
spring.datasource.url=jdbc:mysql://localhost:3306/babaytun?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=root
spring.redis.database=3
spring.redis.port=6379
spring.redis.host=localhost
spring.redis.database=0
spring.redis.jedis.pool.max-active=100
spring.redis.jedis.pool.max-idle=100
spring.redis.jedis.pool.min-idle=10
spring.redis.jedis.pool.max-wait=50000ms
4、初始化监听器,通过这个监听器,我们把要操作的内存队列全部创建出来,
/**
* 系统初始化监听器
* @author asus
*
*/
public class InitListener implements ServletContextListener{
@Override
public void contextInitialized(ServletContextEvent sce) {
System.out.println("系统初始化 =============================");
RequestProcessorThreadPool.init();
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
}
}
可以看到,在监听器里面有一个RequestProcessorThreadPool.init(); 的方法,通过调用这个方法就可以实现创建内存队列的行为,我们就从这个类看起,
5、初始化一个可存入10个线程的线程池,往每个线程里塞满相应的阻塞队列,每个队列里最大可处理数量为100个,可以自定义,为了保证线程安全,这里采用静态内部类的方式,
/**
* 请求处理线程池:单例
*/
public class RequestProcessorThreadPool {
// 实际项目中,你设置线程池大小是多少,每个线程监控的那个内存队列的大小是多少
// 都可以做到一个外部的配置文件中
/**
* 初始化一个可存入10个线程的线程池,往每个线程里塞满10个
*/
private ExecutorService threadPool = Executors.newFixedThreadPool(10);
public RequestProcessorThreadPool() {
RequestQueue requestQueue = RequestQueue.getInstance();
for(int i = 0; i < 10; i++) {
ArrayBlockingQueue queue = new ArrayBlockingQueue(100);
requestQueue.addQueue(queue);
threadPool.submit(new RequestProcessorThread(queue));
}
}
/**
* 采取绝对线程安全的一种方式
* 静态内部类的方式,去初始化单例
*
*/
private static class Singleton {
private static RequestProcessorThreadPool instance;
static {
instance = new RequestProcessorThreadPool();
}
public static RequestProcessorThreadPool getInstance() {
return instance;
}
}
/**
* jvm的机制去保证多线程并发安全
*
* 内部类的初始化,一定只会发生一次,不管多少个线程并发去初始化
*
* @return
*/
public static RequestProcessorThreadPool getInstance() {
return Singleton.getInstance();
}
/**
* 初始化的便捷方法
*/
public static void init() {
getInstance();
}
}
/**
* 请求内存队列
*
*/
public class RequestQueue {
/**
* 内存队列
*/
private List> queues = new ArrayList>();
/**
* 标识位map
*/
private Map flagMap = new ConcurrentHashMap();
/**
* 单例有很多种方式去实现:我采取绝对线程安全的一种方式
*
* 静态内部类的方式,去初始化单例
*
* @author Administrator
*
*/
private static class Singleton {
private static RequestQueue instance;
static {
instance = new RequestQueue();
}
public static RequestQueue getInstance() {
return instance;
}
}
/**
* jvm的机制去保证多线程并发安全
*
* 内部类的初始化,一定只会发生一次,不管多少个线程并发去初始化
*
* @return
*/
public static RequestQueue getInstance() {
return Singleton.getInstance();
}
/**
* 添加一个内存队列
* @param queue
*/
public void addQueue(ArrayBlockingQueue queue) {
this.queues.add(queue);
}
/**
* 获取内存队列的数量
* @return
*/
public int queueSize() {
return queues.size();
}
/**
* 获取内存队列
* @param index
* @return
*/
public ArrayBlockingQueue getQueue(int index) {
return queues.get(index);
}
public Map getFlagMap() {
return flagMap;
}
}
6、接下来,我们来封装具体的请求,一个是请求更新数据库的,一个是读取请求,
【1】更新数据库数据请求:
/**
* 比如说一个商品发生了交易,那么就要修改这个商品对应的库存
* 此时就会发送请求过来,要求修改库存,那么这个可能就是所谓的data update request,数据更新请求
* (1)删除缓存
* (2)更新数据库
*/
public class ProductInventoryDBUpdateRequest implements Request {
private static final Logger logger = LoggerFactory.getLogger(RequestAsyncProcessServiceImpl.class);
/**
* 商品库存
*/
private ProductInventory productInventory;
/**
* 商品库存Service
*/
private ProductInventoryService productInventoryService;
public ProductInventoryDBUpdateRequest(ProductInventory productInventory,
ProductInventoryService productInventoryService) {
this.productInventory = productInventory;
this.productInventoryService = productInventoryService;
}
@Override
public void process() {
logger.info("===========日志===========: "
+ "数据库更新请求开始执行,商品id=" + productInventory.getProductId() +
", 商品库存数量=" + productInventory.getInventoryCnt());
// 删除redis中的缓存
productInventoryService.removeProductInventoryCache(productInventory);
// 为了能够看到效果,模拟先删除了redis中的缓存,然后还没更新数据库的时候,读请求过来了,这里可以人工sleep一下
try {
Thread.sleep(20000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改数据库中的库存
productInventoryService.updateProductInventory(productInventory);
}
/**
* 获取商品id
*/
public Integer getProductId() {
return productInventory.getProductId();
}
@Override
public boolean isForceRefresh() {
return false;
}
}
【2】读取最新数据请求:
/**
* 重新加载商品库存的缓存
*
*/
public class ProductInventoryCacheRefreshRequest implements Request {
private static final Logger logger = LoggerFactory.getLogger(RequestAsyncProcessServiceImpl.class);
/**
* 商品id
*/
private Integer productId;
/**
* 商品库存Service
*/
private ProductInventoryService productInventoryService;
/**
* 是否强制刷新缓存
*/
private boolean forceRefresh;
public ProductInventoryCacheRefreshRequest(Integer productId,
ProductInventoryService productInventoryService,
boolean forceRefresh) {
this.productId = productId;
this.productInventoryService = productInventoryService;
this.forceRefresh = forceRefresh;
}
@Override
public void process() {
// 从数据库中查询最新的商品库存数量
ProductInventory productInventory = productInventoryService.findProductInventory(productId);
logger.info("===========日志===========: 已查询到商品最新的库存数量,商品id=" + productId + ", 商品库存数量=" + productInventory.getInventoryCnt());
// 将最新的商品库存数量,刷新到redis缓存中去
productInventoryService.setProductInventoryCache(productInventory);
}
public Integer getProductId() {
return productId;
}
public boolean isForceRefresh() {
return forceRefresh;
}
}
7、两种请求的具体业务逻辑实现类,
【1】更新商品数据,
/**
* 修改商品库存Service实现类
*
*/
@Service("productInventoryService")
public class ProductInventoryServiceImpl implements ProductInventoryService {
private static final Logger logger = LoggerFactory.getLogger(ProductInventoryController.class);
@Resource
private ProductInventoryMapper productInventoryMapper;
@Autowired
private RedisTemplate redisTemplate;
/**
* 修改数据库商品库存数量
*/
public void updateProductInventory(ProductInventory productInventory) {
productInventoryMapper.updateProductInventory(productInventory);
logger.info("===========日志===========: 已修改数据库中的库存,商品id=" + productInventory.getProductId() + ", 商品库存数量=" + productInventory.getInventoryCnt());
}
/**
* 根据商品的id删除redis中某商品
*/
public void removeProductInventoryCache(ProductInventory productInventory) {
String key = "product:inventory:" + productInventory.getProductId();
//redisTemplate.delete(key);
redisTemplate.delete(key);
logger.info("===========日志===========: 已删除redis中的缓存,key=" + key);
}
/**
* 根据商品id查询商品库存
* @param productId 商品id
* @return 商品库存
*/
public ProductInventory findProductInventory(Integer productId) {
return productInventoryMapper.findProductInventory(productId);
}
/**
* 设置商品库存的缓存
* @param productInventory 商品库存
*/
public void setProductInventoryCache(ProductInventory productInventory) {
String key = "product:inventory:" + productInventory.getProductId();
//redisTemplate.opsForValue().set(key, String.valueOf(productInventory.getInventoryCnt()));
redisTemplate.opsForValue().set(key, String.valueOf(productInventory.getInventoryCnt()));
logger.info("===========日志===========: 已更新商品库存的缓存,商品id=" + productInventory.getProductId() + ", 商品库存数量=" + productInventory.getInventoryCnt() + ", key=" + key);
}
/**
* 获取商品库存的缓存
* @param productId
* @return
*/
public ProductInventory getProductInventoryCache(Integer productId) {
Long inventoryCnt = 0L;
String key = "product:inventory:" + productId;
//String result = (String) redisTemplate.opsForValue().get(key);
String result = (String) redisTemplate.opsForValue().get(key);
if(result != null && !"".equals(result)) {
try {
inventoryCnt = Long.valueOf(result);
return new ProductInventory(productId, inventoryCnt);
} catch (Exception e) {
e.printStackTrace();
}
}
return null;
}
}
【2】读取商品数据,
/**
* 请求读取数据异步处理的service实现
*
*/
@Service("requestAsyncProcessService")
public class RequestAsyncProcessServiceImpl implements RequestAsyncProcessService {
private static final Logger logger = LoggerFactory.getLogger(RequestAsyncProcessServiceImpl.class);
@Override
public void process(Request request) {
try {
// 做请求的路由,根据每个请求的商品id,路由到对应的内存队列中去
ArrayBlockingQueue queue = getRoutingQueue(request.getProductId());
// 将请求放入对应的队列中,完成路由操作
queue.put(request);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取路由到的内存队列
* @param productId 商品id
* @return 内存队列
*/
private ArrayBlockingQueue getRoutingQueue(Integer productId) {
RequestQueue requestQueue = RequestQueue.getInstance();
// 先获取productId的hash值
String key = String.valueOf(productId);
int h;
int hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
// 对hash值取模,将hash值路由到指定的内存队列中,比如内存队列大小8
// 用内存队列的数量对hash值取模之后,结果一定是在0~7之间
// 所以任何一个商品id都会被固定路由到同样的一个内存队列中去的
int index = (requestQueue.queueSize() - 1) & hash;
logger.info("===========日志===========: 路由内存队列,商品id=" + productId + ", 队列索引=" + index);
return requestQueue.getQueue(index);
}
}
在请求读取最新的商品库存数据方法中,有个根据商品的id路由到对应的内存队列的方法,即相同的productId通过这个路由方法之后会进入某个线程下相同的arrayBlockingQueue中,这样就能保证读写的顺序性执行,
8、在初始化创建线程池的方法中,我们注意到有一个方法,即我们把整个queue提交到线程池里面了,其实在这个RequestProcessorThread 就是具体执行请求路由转发的,也就是说,一个前端请求打过来,只要是我们定义的请求类型,就会执行这个里面的转发逻辑,
9、以上就是主要的业务封装类,下面就来看看controller中两种请求的方法类,
/**
* 商品库存Controller
* 要模拟的场景:
*
*(1)一个更新商品库存的请求过来,然后此时会先删除redis中的缓存,然后模拟卡顿5秒钟
*(2)在这个卡顿的5秒钟内,我们发送一个商品缓存的读请求,因为此时redis中没有缓存,就会来请求将数据库中最新的数据刷新到缓存中
*(3)此时读请求会路由到同一个内存队列中,阻塞住,不会执行
*(4)等5秒钟过后,写请求完成了数据库的更新之后,读请求才会执行
*(5)读请求执行的时候,会将最新的库存从数据库中查询出来,然后更新到缓存中
* 如果是不一致的情况,可能会出现说redis中还是库存为100,但是数据库中也许已经更新成了库存为99了
* 现在做了一致性保障的方案之后,就可以保证说,数据是一致的
*
*
*/
@Controller
public class ProductInventoryController {
private static final Logger logger = LoggerFactory.getLogger(ProductInventoryController.class);
@Resource
private RequestAsyncProcessService requestAsyncProcessService;
@Resource
private ProductInventoryService productInventoryService;
/**
* 更新商品库存
*/
@RequestMapping("/updateProductInventory")
@ResponseBody
public Response updateProductInventory(ProductInventory productInventory) {
// 为了简单起见,我们就不用log4j那种日志框架去打印日志了
// 其实log4j也很简单,实际企业中都是用log4j去打印日志的,自己百度一下
logger.info("===========日志===========: 接收到更新商品库存的请求,商品id=" + productInventory.getProductId() + ", 商品库存数量=" + productInventory.getInventoryCnt());
Response response = null;
try {
Request request = new ProductInventoryDBUpdateRequest(productInventory, productInventoryService);
requestAsyncProcessService.process(request);
response = new Response(Response.SUCCESS);
} catch (Exception e) {
e.printStackTrace();
response = new Response(Response.FAILURE);
}
return response;
}
/**
* 获取商品库存
*/
@RequestMapping("/getProductInventory")
@ResponseBody
public ProductInventory getProductInventory(Integer productId) {
logger.info("===========日志===========: 接收到一个商品库存的读请求,商品id=" + productId);
ProductInventory productInventory = null;
try {
Request request = new ProductInventoryCacheRefreshRequest(productId, productInventoryService, false);
requestAsyncProcessService.process(request);
// 将请求扔给service异步去处理以后,就需要while(true)一会儿,在这里hang住
// 去尝试等待前面有商品库存更新的操作,同时缓存刷新的操作,将最新的数据刷新到缓存中
long startTime = System.currentTimeMillis();
long endTime = 0L;
long waitTime = 0L;
// 等待超过200ms没有从缓存中获取到结果
while(true) {
if(waitTime > 25000) {
break;
}
// 一般公司里面,面向用户的读请求控制在200ms就可以了
/*if(waitTime > 200) {
break;
}*/
// 尝试去redis中读取一次商品库存的缓存数据
productInventory = productInventoryService.getProductInventoryCache(productId);
// 如果读取到了结果,那么就返回
if(productInventory != null) {
logger.info("===========日志===========: 在200ms内读取到了redis中的库存缓存,商品id=" + productInventory.getProductId() + ", 商品库存数量=" + productInventory.getInventoryCnt());
return productInventory;
}
// 如果没有读取到结果,那么等待一段时间
else {
Thread.sleep(20);
endTime = System.currentTimeMillis();
waitTime = endTime - startTime;
}
}
// 直接尝试从数据库中读取数据
productInventory = productInventoryService.findProductInventory(productId);
if(productInventory != null) {
// 将缓存刷新一下
// 这个过程,实际上是一个读操作的过程,但是没有放在队列中串行去处理,还是有数据不一致的问题
request = new ProductInventoryCacheRefreshRequest(
productId, productInventoryService, true);
requestAsyncProcessService.process(request);
// 代码会运行到这里,只有三种情况:
// 1、就是说,上一次也是读请求,数据刷入了redis,但是redis LRU算法给清理掉了,标志位还是false
// 所以此时下一个读请求是从缓存中拿不到数据的,再放一个读Request进队列,让数据去刷新一下
// 2、可能在200ms内,就是读请求在队列中一直积压着,没有等待到它执行(在实际生产环境中,基本是比较坑了)
// 所以就直接查一次库,然后给队列里塞进去一个刷新缓存的请求
// 3、数据库里本身就没有,缓存穿透,穿透redis,请求到达mysql库
return productInventory;
}
} catch (Exception e) {
e.printStackTrace();
}
return new ProductInventory(productId, -1L);
}
}
为了模拟出鲜果,我在读取数据的方法里面加了个wait的操作,这里wait的时间要大于请求更新数据库的时间长,因为要确保写请求执行完毕才执行读请求,这样读取到的数据才是最新的数据,就达到了阻塞队列的实际作用,
9、其他的类这里略过了,比较简单,主要是接口、实体类和一个更新数据库数据和查询的,注意,我们配置了监听器,在springboot里面一定要将监听器作为bean配置到启动类里面,否则不生效,
@EnableAutoConfiguration
@SpringBootApplication
@Component
@MapperScan("com.congge.mapper")
public class MainApp {
/**
* 注册web应用启动监听器bean
* @return
*/
@SuppressWarnings("rawtypes")
@Bean
public ServletListenerRegistrationBean servletListenerRegistrationBean(){
ServletListenerRegistrationBean listenerRegistrationBean =
new ServletListenerRegistrationBean();
listenerRegistrationBean.setListener(new InitListener());
return listenerRegistrationBean;
}
public static void main(String[] args) {
SpringApplication.run(MainApp.class, args);
}
}
,
9、数据库中我提前创建好了一张表,并在redis中初始化了一条和数据库相同的数据,
启动项目,首先我们访问,即发起请求,将这条数据的库存变为99,
http://localhost:8085/updateProductInventory?productId=1&inventoryCnt=99
同时立即发起请求,即读取这条数据的库存,即模拟一个并发操作,
http://localhost:8085/getProductInventory?productId=1
总体的效果是,第一个更新请求立即访问,由于后台有wait,所以相应的是null,当过了20秒后,我们继续刷新,更新成功,再发起读取数据请求,第二个请求相应到的数据就是更新完毕的数据99,中间第二个请求会一直卡顿在那儿,
这样就达到了我们的目的,通过上述整合,我们实现了模拟在并发环境下实现mysql和redis中数据读写的一致性,本篇到此结束,感谢观看!