内存键值缓存是不是包含引用值?

Posted

技术标签:

【中文标题】内存键值缓存是不是包含引用值?【英文标题】:Does a memory key-value cache contain referenced values?内存键值缓存是否包含引用值? 【发布时间】:2021-12-10 09:37:23 【问题描述】:

我有点不清楚如何以及是否使用内存缓存。 我正在开发一个创建中世纪 MMO 类型游戏的 Java 应用程序(Minecraft Spigot 插件),其中有很多对象(城镇、结构和具有特定属性的项目等)。 其中许多对象被大量读取和修改,这让我研究了一种缓存解决方案,以防止这些对象开始从 mysql 数据库中获取(这可能不是 MMO 的最佳解决方案,但我想坚持下去目前)。 因此,对于我的具体情况,我认为内存缓存方法似乎是正确的选择。我一直在搜索和挖掘有关缓存和缓存提供程序的大量信息,但我仍然不清楚内存缓存是否需要在每次修改对象时检索和缓存对象。 这是我理解的重要部分,因为有些对象是不断修改的。我们以城门为例。 Gate 对象具有生命值属性,当玩家损坏门时该属性会发生变化。在我的理解中,每次玩家损坏门时获取门对象、更改生命值属性和缓存修改后的对象似乎有点麻烦。尤其是考虑到大多数门对象的默认生命值是 500,玩家的最大伤害输出可以是 20。这意味着如果玩家想要摧毁门对象,对象必须是在几分钟内获取、修改和缓存 25 次。 是否有可能只从缓存中获取对象(假设该对象已从数据库中检索并之前缓存)修改它并保持原样?这当然意味着缓存将保存对对象的引用,并且在修改时缓存将引用修改后的实例。如果这是可能的,在性能方面这是一个好方法吗? 总结一下我的问题:

    考虑到我的情况(经常修改大量对象),内存中(键值)缓存是否合乎逻辑? 在缓存中保存对缓存对象的引用是否可行且高效(在性能方面)?

提前感谢您的帮助!

【问题讨论】:

嗨潘迪!有趣的问题,不幸的是它有很多方面需要考虑。此应用程序是否在多个服务器上运行?可能有多少台服务器?您预计与读取相关的写入次数是多少? 感谢您的评论。我知道这个问题有很多方面和因素会影响哪种方法和解决方案适合我的情况。我仍处于应用程序开发的早期阶段,但对我来说,性能和可伸缩性显然是开发时的关键属性。目前它在一台服务器上运行,并且暂时仍然如此。关于读取和写入的数量,我有点不清楚,我会继续进行一些统计数据。我将在大约 2 小时左右为我最初的问题添加一些统计数据。 在单一服务器上运行使事情变得更容易。您可以将数据保存在内存中,而不必担心其他服务器之间的一致性。您可以使用与 hibernate 集成的缓存,它使用 write behind(例如 google 的 EHCache 和 write behind)。 Write behind 允许您配置写入延迟,因此并非每次写入都会写回数据库。 由于 EHCache 和休眠本身有很多开销,我会亲自执行我自己的回写策略以适应该问题。例如,如果门的运行状况受到轻微影响,您可以跳过数据库写入。 然而,有一种叫做“过早优化”的东西(google it)。我建议您首先关注游戏玩法,并在您开始使用有限的用户时开始优化。在开始大规模优化之前拥有真实的使用数据是很好的。 【参考方案1】:

有趣的问题,因为它涉及性能工程、并发编程的许多方面,尤其是通过性能折衷实现的一致性。

可以在 Hibernate 中启用缓存并配置缓存后写入,例如,可以使用 EHCache。但是,仍然存在相当大的开销,因为 Hibernate 和数据库是为事务性工作负载和一致性而设计的。

我将提出一个基于cache2k 的潜在最优解决方案。我确实利用了 cache2k 中其他缓存中不存在的一些功能,但是,我在这里使用的一些技术可以与其他缓存实现一起使用,例如 EHCache、Guava Cache、Caffeine 或支持按引用存储的 JCache/JSR107 .

由于您在单个服务器上运行,因此使用内存数据是最有效的。可以跳过或延迟写入,因为您没有银行账户等交易数据。在服务器崩溃的情况下,丢失一点点状态更新是可以容忍的。它总是在更新性能和崩溃时潜在的数据丢失之间进行权衡。

您可以在地图或缓存中保存当前状态,然后更新 现有对象。示例:

class Gate 
  final AtomicInteger health = new AtomicInteger(100);

Cache<Id, Gate> cache = ....;

void decreaseHealth(Id id, int damage) 
  Gate gate = cache.get(id);
  gate.health.addAndGet(-damage);

我稍后会展示与数据库交互的附加代码,并首先关注内存中的更新。

如果您使用映射而不是缓存,则需要使用线程安全的映射,例如ConcurrentHashMap

由于更改可能同时发生,您需要使用一种方法来自动更新运行状况。上面我使用了AtomicInteger。另一种可能性是原子更新程序或 var 句柄。如果您只更新单个值,这是最有效的方法,因为它转换为硬件上的单个 CAS 操作。如果您更新对象中的多个值,请使用锁、synchronized 或缓存/映射条目上的原子操作。示例:

class Gate 
  int health = 100;
  // ....

cache.asMap().compute(id, (unused, gate) -> 
   gate.health -= damage;
   if (gate.health == 0) 
     // more changes to the object if destroyed totally
   
   return gate;
 );

这是一个基于 cache2k 的工作解决方案的想法,它会在发生重大变化时安排写入延迟。

  // mocks
  class Id 
  Gate readFromDb()  return null; 
  void writeDb(Gate g)  

  class Gate 
    final AtomicInteger health = new AtomicInteger(100);
    volatile boolean writeScheduled = false;
    final AtomicInteger persistentHealth = new AtomicInteger(100);
    boolean isDirty() 
      return persistentHealth.get() != health.get();
    
  

  Cache<Id, Gate> cache =
    new Cache2kBuilder<Id, Gate>() 
      .loader((id, l, cacheEntry) -> 
        if (cacheEntry == null)  return readFromDb(); 
        Gate gate = cacheEntry.getValue();
        return gate;
      )
      .addListener((CacheEntryExpiredListener<Id, Gate>) (cache, cacheEntry) -> 
        writeIfModified(cacheEntry.getValue());
      )
      .refreshAhead(true)
      .keepDataAfterExpired(true)
      .expireAfterWrite(5, TimeUnit.MINUTES)
      .loaderExecutor(Executors.newFixedThreadPool(30))
      .build();

  void writeIfModified(Gate gate) 
    if (!gate.isDirty())  return; 
    int persistentHealth = gate.health.get();
    writeDb(gate);
    gate.writeScheduled = false;
    gate.persistentHealth.set(persistentHealth);
  

  long writeBehindDelayMillis = 500;
  int changePercentage = 10;

  public void decreaseHealth(Id id, int damage) 
    Gate gate = cache.get(id);
    int persistentHealth = gate.persistentHealth.get();
    int newHealth = gate.health.addAndGet(-damage);
    if (!gate.writeScheduled) 
      int percentage = persistentHealth * 10 / 100;
      if (newHealth > persistentHealth + percentage ||
        newHealth < persistentHealth - percentage) 
        cache.invoke(id, entry -> 
          entry.setValue(entry.getValue());
          entry.setExpiryTime(entry.getStartTime() + writeBehindDelayMillis);
          entry.getValue().writeScheduled = true;
          return null;
        );
      
    
  

这会以通读模式操作缓存,因此cache.get() 会触发初始数据库加载。此外,如果需要,它使用到期和提前刷新来安排延迟写入。这有点棘手,因为通常和“记录”的提前刷新用例是不同的。如果有兴趣,我可以在博客文章中解释详细的机制。对于 Stack Overflow 的答案来说,它有点太重了。

当然,您也可以将一些想法与其他缓存实现一起使用。

最后一点:如果地图计算用于原子性,如果并发同步数据库写入正在进行,操作可能会阻塞。要么进行异步写入,要么使用不同的锁定进行更新。

通过 JPA 缓存与回写方法进行性能比较仍然会很有趣。

【讨论】:

【参考方案2】:

是的,需要缓存。发出 SQL 请求太长。比如它保存在 RAM 中,它显然更快。

如何保存它们?

只需使用地图。

你必须根据你的数据类型,特别是key来选择map的类型。

您可以通过here或here获取更多相关信息。

使用缓存管理器,例如解释here with MapMaker(来自 Guava)

您可以制作自己的缓存管理器。有时特意打扫,像这样:

public HashMap<UUID, Data> datas = new HashMap<>(); // here you have to choose the good map type

public void onEnable() 
   getServer().getScheduler().runTaskTimerAsynchronously(this, () -> 
      synchronized(datas)  // prevent used from others thread
         datas.values().forEach(Data::save); // save all data
         datas.clear();
      
   , 20 * 60, 20 * 60); // in ticks, 20 ticks = 1s so 20*60 = 1min

在这里,您每分钟保存所有对象并清除它们。清除是为了防止数据过多。

我建议你结合多种东西:使用缓存,并在长时间不使用时保存。

【讨论】:

以上是关于内存键值缓存是不是包含引用值?的主要内容,如果未能解决你的问题,请参考以下文章

你说,Redis如何实现键值自动清理?

Memcached 设置值set和取值get用法

需要 PHP 中的分布式键值查找系统

Python入门-3序列:17字典-核心底层原理-内存分析-存储键值对过程

LAMP整合Redis键值缓存为库分担压力

android上持久键值存储的最佳机制是啥(具有大值)