在分布式并发系统中,数据库与缓存数据一致性是一项富有挑战性的技术难点。假设有完善的工业级分布式事务解决方案,那么数据库与缓存数据一致性便迎刃而解,实际上,目前分布式事务不成熟。
在数据库与缓存数据一致解决方式中,有各种声音。
先操作数据库后缓存还是先缓存后数据库
缓存是更新还是删除
在并发系统中,数据库与缓存双写场景下,为了追求更大的并发量,操作数据库与缓存显而易见不会同步进行。前者操作成功后者以异步的方式进行。
关系型数据库作为成熟的工业级数据存储方案,有完善的事务处理机制,数据一旦落盘,不考虑硬件故障,可以负责任的说数据不会丢失。
所谓缓存,无非是存储在内存中的数据,服务一旦重启,缓存数据全部丢失。既然称之为缓存,那么时刻做好了缓存数据丢失的准备。尽管Redis有持久化机制,是否能够保证百分之百持久化?Redis将数据异步持久化到磁盘有不可,缓存是缓存,数据库是数据库,两个不同的东西。把缓存当数据库使用是一件极其危险的事情。
从数据安全的角度来讲,先操作数据库,然后以异步的方式操作缓存,响应用户请求。
缓存是更新还是删除,对应懒汉式和饱汉式,从处理线程安全实践来讲,删除缓存操作相对难度低一些。如果在删除缓存的前提下满足了查询性能,那么优先选择删除缓存。
更新缓存尽管能够提高查询效率,然后带来的线程并发脏数据处理起来较麻烦,序言引入MQ等其它消息中间件,因此非必要不推荐。
理解线程并发所带来问题的关键是先理解系统中断,操作系统在任务调度时,中断随时都在发生,这是线程数据不一致产生的根源。以4和8线程CPU为例,同一时刻最多处理8个线程,然而操作系统管理的线程远远超过8个,因此线程们以一种看似并行的方式进行。
在非并发环境中,使用如下方式查询数据并无不妥:先查询缓存,如果缓存数据不存在,查询数据库,更新缓存,返回结果。
public BuOrder getOrder(Long orderId) { String key = ORDER_KEY_PREFIX + orderId; BuOrder buOrder = RedisUtils.getObject(key, BuOrder.class); if (buOrder != null) { return buOrder; } BuOrder order = getById(orderId); RedisUtils.setObject(key, order, 5, TimeUnit.MINUTES); return order; }
如果在高并发环境中有一个严重缺陷:当缓存失效时,大量查询请求涌入,瞬间全部打到DB上,轻则数据库连接资源耗尽,用户端响应500错误,重则数据库压力过大服务宕机。
因此在并发环境中,需要对上述代码进行修改,使用分布式锁。大量请求涌入时,获得锁的线程有机会访问数据库查询数据,其余线程阻塞。当查询完数据并更新缓存,然后释放锁。等待的线程重新检查缓存,发现能够获取到数据,直接将缓存数据响应。
这里提到分布式锁,那么使用表锁还是行锁呢?使用分布式行锁提高并发量;使用二次检查机制,确保等待获得锁的线程能够快速返回结果
@Override public BuOrder getOrder(Long orderId) { /* 如果缓存不存在,则添加分布式锁更新缓存 */ String key = ORDER_KEY_PREFIX + orderId; BuOrder order = RedisUtils.getObject(key, BuOrder.class); if (order != null) { return order; } String orderLock = ORDER_LOCK + orderId; RLock lock = redissonClient.getLock(orderLock); if (lock.tryLock()) { order = RedisUtils.getObject(key, BuOrder.class); if (order != null) { LockOptional.ofNullable(lock).ifLocked(RLock::unlock); return order; } BuOrder buOrder = getById(orderId); RedisUtils.setObject(key, buOrder, 5, TimeUnit.MINUTES); LockOptional.ofNullable(lock).ifLocked(RLock::unlock); } return RedisUtils.getObject(key, BuOrder.class); }
非并发环境中,如下代码尽管可能会产生数据不一致问题(数据被覆盖)。尽管使用数据库层面乐观锁能够解决数据被覆盖问题,然而无效更新流量依旧会流向数据库。
public Boolean editOrder(BuOrder order) { /* 更新数据库 */ updateById(order); /* 删除缓存 */ RedisUtils.deleteObject(OrderServiceImpl.ORDER_KEY_PREFIX + order.getOrderId()); return true; }
上面分析中使用数据库乐观锁能够解决并发更新中数据被覆盖的问题,然而当同一行记录被修改后,版本号发生改变,后续并发流向数据库的请求为无效流量。减小数据库压力的首要策略是将无效流量拦截在数据库之前。
使用分布式锁能够保证并发流量有序访问数据库,考虑到数据库层面已经使用了乐观锁,第二个及以后获得锁的线程操作数据库为无效流量。
线程在获得锁时采用超时退出的策略,等待获得锁的线程超时快速退出,快速响应用户请求,重试更新数据操作。
public Boolean editOrder(BuOrder order) { String orderLock = ORDER_LOCK + order.getOrderId(); RLock lock = redissonClient.getLock(orderLock); try { /* 超时未获取到锁,快速失败,用户端重试 */ if (lock.tryLock(1, TimeUnit.SECONDS)) { /* 更新数据库 */ updateById(order); /* 删除缓存 */ RedisUtils.deleteObject(OrderServiceImpl.ORDER_KEY_PREFIX + order.getOrderId()); /* 释放锁 */ LockOptional.ofNullable(lock).ifLocked(RLock::unlock); return true; } } catch (InterruptedException e) { e.printStackTrace(); } return false; }
上述代码使用了封装锁的工具类。
<dependency> <groupId>xin.altitude.cms</groupId> <artifactId>ucode-cms-common</artifactId> <version>1.4.3.2</version> </dependency>
LockOptional
根据锁的状态执行后续操作。
接下来讨论先更新数据库,后删除缓存是否存在并发问题。
(1)缓存刚好失效
(2)请求A查询数据库,得一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查到的旧值写入缓存
Der Schlüssel zum oben genannten Parallelitätsproblem liegt darin, dass Schritt 5 nach den Schritten 3 und 4 auftritt. Aus den unsicheren Faktoren der Betriebssystemunterbrechung ist ersichtlich, dass diese Situation auftreten kann.
Aus der tatsächlichen Situation nimmt das Schreiben von Daten in Redis weitaus weniger Zeit in Anspruch als das Schreiben von Daten in die Datenbank. Obwohl die Wahrscheinlichkeit eines Auftretens gering ist, wird es trotzdem passieren.
(1) Erhöhen Sie die Cache-Ablaufzeit
Durch die Erhöhung der Cache-Ablaufzeit können fehlerhafte Daten innerhalb eines bestimmten Zeitraums vorhanden sein, bis die nächste gleichzeitige Aktualisierung erfolgt, und möglicherweise werden fehlerhafte Daten angezeigt. Es liegen regelmäßig fehlerhafte Daten vor.
(2) Aktualisierungen und Abfragen teilen sich eine Zeilensperre
Aktualisierungen und Abfragen teilen sich eine verteilte Zeilensperre, und die oben genannten Probleme bestehen nicht mehr. Wenn die Leseanforderung die Sperre erhält, befindet sich die Schreibanforderung in einem blockierten Zustand (die Zeitüberschreitung schlägt fehl und kehrt schnell zurück), wodurch sichergestellt wird, dass Schritt 5 vor Schritt 3 ausgeführt wird.
(3) Cache-Löschung verzögern
Verwenden Sie RabbitMQ, um das Cache-Löschen zu verzögern und die Auswirkungen von Schritt 5 zu beseitigen. Die Verwendung einer asynchronen Methode hat nahezu keine Auswirkungen auf die Leistung.
Die Datenbank verfügt über einen Transaktionsmechanismus, um den Erfolg des Vorgangs sicherzustellen. Eine einzelne Redis-Anweisung weist jedoch keine atomaren Eigenschaften auf. Insbesondere ist der Datenbankvorgang erfolgreich und die Anwendung hängt dann abnormal , was dazu führt, dass das Löschen des Redis-Cache fehlschlägt. Dieses Problem tritt auf, wenn bei der Redis-Dienstnetzwerkverbindung eine Zeitüberschreitung auftritt.
Wenn eine Cache-Ablaufzeit festgelegt ist, sind schmutzige Daten immer vorhanden, bevor der Cache abläuft. Wenn die Ablaufzeit nicht festgelegt ist, bleiben fehlerhafte Daten bestehen, bis die Daten das nächste Mal geändert werden. (Die Datenbankdaten haben sich geändert und der Cache wurde nicht aktualisiert)
Schreiben Sie vor dem Betrieb der Datenbank eine Nachricht zum verzögerten Löschen des Caches an RabbitMQ, führen Sie dann den Datenbankvorgang und den Cache-Löschvorgang durch. Unabhängig davon, ob der Cache auf Codeebene erfolgreich gelöscht wurde, löscht MQ den Cache als garantierten Vorgang.
Das obige ist der detaillierte Inhalt vonWas ist das Datenbank- und Cache-Datenkonsistenzschema für die gleichzeitige Java-Programmierung?. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!