当前位置: 首页 > 科技观察

亿级流量高并发下缓存和数据库不一致怎么办?_0

时间:2023-03-17 15:50:11 科技观察

我相信只要是像样的互联网公司,或多或少都会有自己的一套缓存系统。图片来自Pexels。只要用到缓存,就可能涉及到缓存和数据库的双存双写。只要是双写,就一定会出现数据一致性问题。所以,在这里想跟大家聊聊:一致性问题怎么解决?如何保证缓存和数据库的双写一致性也是Java面试中面试官最喜欢问的问题!一般来说,如果允许缓存偶尔和数据库稍微不一致,也就是说如果你的系统没有严格要求缓存+数据库必须一致,最好不要做这种方案。即:将读请求和写请求序列化放入一个内存队列中,防止并发请求造成数据混乱的问题。场景如图:值得注意的是,序列化可以保证不会发生不一致,但也会导致系统吞吐量大幅下降,使用比正常多几倍的机器来支持线上的一个请求(土豪请自觉无视此提醒)。解决思路如下图:代码实现大致如下:/***请求异步处理服务实现*@authorAdministrator**/@Service("requestAsyncProcessService"){//先做读请求DeduplicationRequestQueuerequestQueue=RequestQueue.getInstance();MapflagMap=requestQueue.getFlagMap();if(requestinstanceofProductInventoryDBUpdateRequest){//如果是更新数据库的请求,则设置productId对应的identifier为trueflagMap.put(request.getProductId(),true);}elseif(requestinstanceofProductInventoryCacheRefreshRequest){Booleanflag=flagMap.get(request.getProductId());//如果flag为nullif(flag==null){flagMap.put(request.getProductId(),false);}//如果是缓存刷新请求,则判断,如果identifier不为空且为true,说明有databaseupdaterequestforthisproductbeforeif(flag!=null&&flag){flagMap.put(request.getProductId(),false);}//如果是缓存刷新请求,发现identifier不为空,但是标识符为false//说明已经有数据库更新请求+缓存刷新请求了,想想if(flag!=null&&!flag){//这种读请求,直接过滤掉,不要放在return后面的内存队列中;}}//做请求的路由,根据每个请求的itemid,路由到对应内存队列到ArrayBlockingQueuequeue=getRoutingQueue(request.getProductId());//将请求放入对应队列完成路由操作queue.put(request);}catch(Exceptione){e.printStackTrace();}}/***获取内存队列路由到*@paramproductId商品id*@return内存队列*/privateArrayBlockingQueuegetRoutingQueue(IntegerproductId){RequestQueuerequestQueue=RequestQueue.getInstance();//先获取productId的哈希值Stringkey=String.valueOf(productId);inth;inthash=(key==null)?0:(h=key.hashCode())^(h>>>16);//为哈希值取模将hash值路由到指定的内存队列,例如内存队列的大小为8//hash值与内存队列数取模后,结果一定在0到7之间//因此任何产品id都将是intindex=(requestQueue.queueSize()-1)&hash;System.out.println("============log===========日志===============:路由内存oryqueue,commodityid="+productId+",queueindex="+index";returnrequestQueue.getQueue(index);}}CacheAsidePattern说说经典的缓存+数据库读写CacheAsidePattern的模式是先读取缓存。如果没有缓存,则读取数据库,然后取出数据放入缓存,同时返回response。更新时,先更新数据库,再删除缓存。为什么删除缓存而不是更新缓存?原因很简单。很多时候,在复杂的缓存场景中,缓存的不仅仅是直接从数据库中取值。比如某个表的某个字段可能更新了,那么对应的缓存就需要查询另外两个表的数据并进行计算,计算出缓存的最新值。此外,更新缓存的成本有时非常高。是不是每次修改数据库都要更新对应的缓存?在某些场景下可能是这样,但是对于更复杂的缓存数据计算场景就不是这样了。如果频繁修改缓存中涉及的多个表,缓存也会频繁更新。但问题是,这个缓存会不会被频繁访问?比如缓存涉及的表的字段在1分钟内修改了20次或者100次,那么缓存就更新了20次或者100次。但是这个缓存1分钟只读1次,有很多冷数据。实际上,如果只是删除缓存,那么在1分钟内,缓存只会重新计算一次,开销大大降低,并且只在使用缓存的时候才计算缓存。其实删除缓存而不是更新缓存是一种惰性计算的思路。不要每次都重新做复杂的计算,不管会不会用到,而是让它在需要用到的时候重新计算。像Mybatis和Hibernate一样,都有懒加载的思想。查询一个部门时,该部门有一个员工列表。不用说,每查询一个部门,里面的1000个员工的数据也是同时被查出来的。80%的情况下,要查询这个部门,只需要访问这个部门的信息即可。先查看部门,同时访问里面的员工。那么只有当你要访问里面的employees的时候,才会去数据库里面查询1000个employees。最基本的缓存不一致问题及解决方法:先修改数据库,再删除缓存。如果删除缓存失败,会导致数据库中的新数据和缓存中的旧数据,数据不一致。解决办法:先删除缓存,再修改数据库。如果修改数据库失败,数据库中有旧数据,缓存为空,数据不会不一致。因为读取的时候没有缓存,读取的是数据库中的旧数据,然后更新到缓存中。比较复杂的数据不一致问题分析数据发生变化,先删除缓存,再修改数据库。但是还没来得及修改,来了一个请求,读取缓存,发现缓存为空,查询数据库,找到修改前的旧数据,放到缓存中。随后的数据更改过程完成数据库修改。完了,数据库和缓存中的数据不一样了。为什么缓存在亿级流量、高并发的场景下会出现这个问题呢?只有在同时读取和写入数据时才会出现此问题。如果你的并发量很低,尤其是读并发量很低,每天只有10000次访问,那么在极少数情况下,会出现刚刚描述的不一致的场景。但是问题是,如果每天上亿流量,每秒几万个并发读,只要每秒有一个数据更新请求,就有可能出现上述的数据库+缓存不一致的情况。解决方案如下:更新数据时,根据数据的唯一标识,将操作路由发送到一个JVM内部队列。读取数据时,如果发现数据不在缓存中,则重新读取数据+更新缓存的操作会根据唯一标识路由后发送到同一个JVM内部队列。一个队列对应一个工作线程,每个工作线程依次获取对应的操作,然后一个一个执行。在这种情况下,对于一个数据更改操作,先删除缓存,然后再更新数据库,但是更新还没有完成。这时候如果有读请求过来,读取一个空的缓存,可以先把缓存更新请求发送到队列中,此时会积压在队列中,然后同步等待缓存更新完成。这里有个优化点。其实把多个updatecache请求串在一个队列里是没有意义的,可以做过滤。如果发现队列中已经有更新缓存的请求,那么就不需要再放入一个更新请求操作,只需等待上一个更新操作请求完成即可。该队列对应的工作线程完成上一个操作对数据库的修改后,会执行下一个操作,即缓存更新操作。这时候会从数据库中读取最新的值,然后写入到缓存中。如果请求还在等待时间范围内,并且可以通过不断轮询得到值,则直接返回;如果请求等待超过一定时间,那么这次直接从数据库中读取当前的旧值。高并发场景下,解决方案要注意:读请求长时间阻塞。因为读请求是非常轻微的异步,所以一定要注意读超时的问题。每个读取请求必须在超时期限内。返回。这种方案最大的风险是数据可能会频繁更新,导致大量更新操作积压在队列中,然后大量的读请求会超时,最后大量的请求会直接进入数据库。所以一定要运行一些真实的测试来查看数据的更新频率。另外,由于一个队列中多个数据项的更新操作可能会积压,需要根据自己的业务情况进行测试。你可能需要部署多个服务,每个服务会共享一些数据更新操作。如果内存队列中有100个项目的库存修改操作积压,则每个库存修改操作将需要10ms才能完成。那么最后一个产品的读请求可能会等待10*100=1000ms=1s才拿到数据,会导致读请求长期阻塞。因此,需要根据实际业务系统的运行情况,进行一些压力测试,模拟线上环境,看看在最繁忙的时候,内存队列中可能积压了多少更新操作,可能会引起相应的读操作最后一个更新操作请求的,它会挂多久。如果读请求在200ms返回,你算一下,即使最忙的时候,也积压了10个update操作,最多等200ms,那就没问题了。如果一个内存队列中有很多更新操作可能积压,那么就需要增加机器,让部署在每台机器上的服务实例处理的数据更少,这样每个内存队列中更新操作的积压就会更少.根据之前的项目经验,一般来说,数据写入的频率是很低的,所以其实正常情况下,队列中积压的更新操作应该很少。一般来说,像这种读高并发读缓存架构的项目写请求很少,能有几百个每秒的QPS就不错了。实际粗略计算一下,如果每秒有500次写操作,分成5个时间片,每200ms进行100次写操作,放在20个内存队列中,每个内存队列可能会积压5次写操作。每次写操作经过性能测试,一般在20ms左右完成,所以对每个内存队列的数据的读请求最多会挂一会,200ms内肯定会返回。经过刚才的简单计算,我们知道单机支持的写QPS在几百是没有问题的。如果写QPS扩大10倍,那就扩大机器,扩大机器10倍,每台机器20个队列。如果读请求的并发量过高,这里必须做压力测试,保证当出现上述情况时,还有一个风险,就是大量的读请求会延迟挂在服务上几十毫秒看服务能不能承载,需要多少台机器承载最大极限情况的峰值。但是因为不是同时更新所有的数据,所以缓存不会同时失效,所以每次都可能有少量数据的缓存失效,然后那些数据对应的读请求就来了,并发量不要特别大。多服务实例部署的请求路由可能会部署该服务的多个实例,因此必须保证执行数据更新操作和缓存更新操作的请求通过Nginx服务器路由到同一个服务实例。例如,对同一产品的所有读写请求都路由到同一台机器。可以根据某个请求参数自己在服务之间做Hash路由,也可以使用Nginx的Hash路由功能等等。热门产品的路由问题导致请求倾斜。如果某个产品的读写请求特别高,全部发送到同一台机器的同一个队列,可能会导致某台机器压力过大。因为只有商品数据更新时才会清空缓存,然后会导致并发读写,所以要看业务系统。如果更新频率不是太高的话,这个问题的影响不是特别大,但是对一些机器的负载会比较高。

猜你喜欢