【问题标题】:thread safe or not my class?线程安全与否我的课?
【发布时间】:2015-07-05 10:07:38
【问题描述】:

服务必须将数据缓存在内存中并将数据保存在数据库中。 getAmount(id) 检索当前余额或为零,如果之前未调用 addAmount() 方法 指定的标识。如果第一次调用方法,addAmount(id, amount) 会增加余额或设置。服务必须是线程安全的。线程安全是我的实现吗?可以做哪些改进?

公共类 AccountServiceImpl 实现 AccountService { 私有静态最终记录器 LOGGER = LoggerFactory.getLogger(AccountServiceImpl.class); 私有 LoadingCache 缓存; 私人 AccountDAO accountDAO = new AccountDAOImpl(); public AccountServiceImpl() { cache = CacheBuilder.newBuilder() .expireAfterAccess(1, TimeUnit.HOURS) .concurrencyLevel(4) .maximumSize(10000) .recordStats() .build(new CacheLoader<Integer, Account>() { @Override public Account load(Integer id) throws Exception { return new Account(id, accountDAO.getAmountById(id)); } }); } public Long getAmount(Integer id) throws Exception { synchronized (cache.get(id)) { return cache.get(id).getAmount(); } } public void addAmount(Integer id, Long value) throws Exception { Account account = cache.get(id); synchronized (account) { accountDAO.addAmount(id, value); account.setAmount(accountDAO.getAmountById(id)); cache.put(id, account); } }

}

【问题讨论】:

    标签: java multithreading caching


    【解决方案1】:

    如果帐户从缓存中被逐出并且正在对该帐户进行多次更新,则可能会发生争用情况。逐出会导致多个 Account 实例,因此同步不提供原子性,并且可能会将过时的值插入缓存中。

    如果您更改设置,比赛会更加明显,例如maximumSize(0)。在当前设置下,比赛的可能性可能很小,但即使在访问之后仍可能发生驱逐。这是因为该条目可能被选择用于驱逐但尚未删除,因此即使从策略的角度来看访问被忽略,后续读取也会成功。

    在 Guava 中执行此操作的正确方法是 Cache.invalidate() 条目。 DAO 以事务方式更新记录系统,因此它确保了操作的原子性。 LoadingCache 确保正在计算的条目的原子性,因此在加载新值时将阻止读取。这会导致额外的数据库查找,这似乎是不必要的,但在实践中可以忽略不计。不幸的是,仍然存在很小的潜在竞争,因为 Guava 不会使加载条目无效。

    Guava 不支持您尝试实现的直写缓存行为。它的继任者Caffeine 公开了Java 8 的compute 映射方法,并很快公开了CacheWriter 抽象。也就是说,Guava 期望的加载方法比手动更新更简单、优雅且不易出错。

    【讨论】:

    • 我在我的question 之一中使用番石榴缓存,其中一个线程将数据填充到其中,如果收到该记录的确认,其他线程将删除。而且我正在尝试在记录级别实现各种 RetryPolicy 以便它可以相应地重试。想看看你有什么想法吗?
    • 您是否考虑过使用failsafeguava-retrying
    • hmmm 有趣的想法.. 在我的情况下这将如何工作?我的意思是使用番石榴重试以记录级别重试?基本上混乱是我如何在我的代码中集成番石榴重试
    • @david 我也没有使用过,但我想你会为每条记录制作一个新的。我认为您不需要您的retryQueue 等,而是委托给这些库之一。您可能希望使用 failsafe's 异步重试。
    【解决方案2】:

    这里有两个问题需要注意:

    1. 金额值的更新必须是原子的。

    如果您已声明:

    class Account { long amount; }
    

    在 32 位系统上更改字段值不是原子操作。它在 64 位系统上是原子的。见:Are 64 bit assignments in Java atomic on a 32 bit machine?

    因此,最好的方法是将声明更改为“volatile long amout;”那么值的更新始终是原子的,另外,volatile 确保其他线程/CPU 看到更改的值。

    这意味着要更新单个值,您不需要同步块。

    1. 插入和修改之间的竞争

    使用同步语句,您只需解决第一个问题。但是您的代码中有多个种族。

    查看此代码:

    synchronized (cache.get(id)) {
        return cache.get(id).getAmount();
    }
    

    你显然假设 cache.get(id) 如果调用相同的 id 返回相同的对象实例。事实并非如此,因为缓存本质上并不能保证这一点。

    Guava Cache 会一直阻塞,直到加载完成。其他缓存可能会阻塞也可能不会阻塞,这意味着如果请求并行进入,将调用多个加载,从而导致存储的缓存值发生多次更改。

    不过,Guava Cache 是一个缓存,因此该项目可能随时从缓存中被逐出,因此在下一次 get 时返回另一个实例。

    同样的问题:

    public void addAmount(Integer id, Long value) throws Exception {
       Account account = cache.get(id);
       /* what happens if lots of requests come in and another 
          threads evict the account object from the cache? */
       synchronized (account) {
          . . .
    

    一般来说:永远不要在生命周期不受您控制的对象上进行同步。顺便说一句:其他缓存实现可能只存储序列化的对象值并在每次请求时返回另一个实例。

    由于您在修改后有一个 cache.put,您的解决方案可能会起作用。但是,同步确实只是完成了刷新内存的目的,它可能会也可能不会真正执行锁定。

    缓存的更新发生在数据库中的值更改之后。这意味着应用程序可以读取以前的值,即使它已经在数据库中更改。这可能会导致不一致。

    解决方案 1

    拥有一组由键值选择的静态锁定对象,例如通过锁[id % locks.length]。在这里查看我的答案:Guava Cache, how to block access while doing removal

    解决方案 2

    使用数据库事务,并使用模式更新:

    Transaction.begin();
    cache.remove(id);
    accountDAO.addAmount(id, value);
    Transaction.commit();
    

    不要直接在缓存中更新值。这将导致更新竞赛并需要再次锁定。

    如果事务仅在 DAO 中处理,这意味着对于您的软件架构,缓存应该在 DAO 中而不是外部实现。

    解决方案 3

    为什么不将金额值存储在缓存中?如果允许更新时缓存结果与数据库内容不一致,最简单的解决方法是:

    public AccountServiceImpl() {
        cache = CacheBuilder.newBuilder()
            .expireAfterAccess(1, TimeUnit.HOURS)
            .concurrencyLevel(4)
            .maximumSize(10000)
            .recordStats()
            .build(new CacheLoader<Integer, Account>() {
                @Override
                public Account load(Integer id) throws Exception {
                    return accountDAO.getAmountById(id);
                }
            });
    }
    
    Long getAmount(Integer id) {
      return cache.get(id);
    } 
    
    void addAmount(Integer id, Long value) {
      accountDAO.addAmount(id, value);
      cache.remove(id);
    }
    

    【讨论】:

      【解决方案3】:

      不,

       private LoadingCache cache;
      

      必须是最终的。

      cache.get(id)
      

      必须同步。您是否为此使用了库?

      【讨论】:

      • 我使用 Guava 库。方法 cache.get (id) 是线程安全的。
      【解决方案4】:

      缓存必须同步。否则两个线程同时更新量,你永远无法确定最终结果。检查使用的库的 `put' 方法的实现

      【讨论】:

        猜你喜欢
        • 2018-01-09
        • 1970-01-01
        • 1970-01-01
        • 2010-11-06
        • 1970-01-01
        • 1970-01-01
        • 2013-11-03
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多