首页 > 数据库 >解决缓存与数据库同步下的同步锁问题之分段锁

解决缓存与数据库同步下的同步锁问题之分段锁

时间:2024-02-04 11:55:05浏览次数:31  
标签:缓存 分段 对象 lock hashcode 同步 线程 ID

契子

  在实际业务会我们会使用第三方的缓存例如:Reids、Memcache等;但是,并且我们在查询使用缓存时都得尽可能的保证缓存的一致性,在读取时得保证尽可能的保证缓存拿到的是数据库的最新数据,那么在实现的逻辑上一般都为这样:

1、请求线程先读取缓存实现

2、如果缓存没有数据的话触发读取数据库动作

3、将从数据库读取的数据写入缓存

线程安全问题

   一般在这里进行缓存加载时都会使用延迟双删的策略来实现缓存的更新,尽可能的避免出现脏缓存的情况。Redis延迟双删:延时双删(redis-mysql)数据一致性思考 - 知乎

        那么在用户请求打到服务端的缓存实现时,如果,只是单纯单个用户时那就不用考虑多个线程同时进入缓存策略导致缓存复写性能开销问题;但是,实际业务中肯定会出现多个用户请求同时请求同一ID资源的情况。

        出现多个用户请求同一ID资源的情况可能会是这样

1、用户A请求进入缓存A点,并且判断缓存不存在开始读取数据库数据

2、用户B请求进入缓存A点,判断缓存发现缓存也不存在开始读取数据库数据

3、用户A请求读取完数据库数据并且将最新的获取到的数据库数据回写缓存;然后再响应用户端

4、用户B请求读取完数据库数据并且将最新的获取到的数据库数据回写缓存;然后再响应用户端

        通过上面的步骤分解我们可以发现3、4点时可能会重复执行,现在举例只是2个请求的情况,试想一下如果实际情况出现很多请求时会不会出现缓存雪崩的情况?缓存雪崩:缓存雪崩产生原因与解决方案 - 知乎

​同步锁

   看到这里时可能已经想起了单例模式(单例模式 - 知乎)的情况,单例模式也是为了解决多个线程同时调用类变量导致重复创建对象,那么这里同理-多个请求访问导致频繁读取数据库使缓存实现逻辑形同虚设。根据单例模式的情况那我们能不能在这里设计一个同步块或者同步锁呢?很明显也是可以的,我们一起来看下如何实现

Codes

    Lock lock = new ReentrantLock();
    public List<Resource> getRsource(Long resourceId){
        // 查询缓存
        List<Resource> resourceEntites = cacheService.findByCacheKey(resourceId);
        if(CollectionUtils.isEmpty(resourceEntites)) {
            // 加锁
            lock.lock();
            try {// 如果缓存不存在
                // 查询数据库
                ResourceEntites = resourceService.findById(resourceId);
                // 回写缓存
                cacheService.setByCacheKey(resourceId, ResourceEntites);
            } finally {
                // 释放锁
                lock.unlock();
            }
        }
        return resourceEntites;
    }

  如上面代码实现所示使用ReentrantLock所在同步锁,但缓存不存在时会进入查询数据库的逻辑。初看之下逻辑看似并没有什么问,但是,细心的人已经发现问题所在了,首先就是缓存为空判断这里,如果,缓存为空的情况下用户请求的线程会进入数据库代码块,由于同步锁的存在所以此代码块只能同时只能被一个线程所持有,那么后续请求的线程将会阻塞,直到第一次获取到的线程释放锁,那么后续的线程就会被唤醒开始竞争锁;

同步双检锁

   这里有各问题就是后续阻塞的线程会进入查询数据库数据的操作,并且也会执行回写缓存的逻辑,因为缓存这里判断已经进入查询数据库的锁机,那么这里也应该与单例模式一样使用双检锁,就是防止后续线程重复执行查询数据库的逻辑。

修改后的代码

    Lock lock = new ReentrantLock();
    public List<Resource> getRsource(Long resourceId){
        // 查询缓存
        List<Resource> resourceEntites = cacheService.findByCacheKey(resourceId);
        if(CollectionUtils.isEmpty(resourceEntites)) {
            // 加锁
            lock.lock();
            // 再次检查防止阻塞线程复写
            if(CollectionUtils.isEmpty(resourceEntites)) {
                try {// 如果缓存不存在
                    // 查询数据库
                    ResourceEntites = resourceService.findById(resourceId);
                    // 回写缓存
                    cacheService.setByCacheKey(resourceId, ResourceEntites);
                } finally {
                    // 释放锁
                    lock.unlock();
                }
            }
        }
        return resourceEntites;
    }

锁的细粒度问题        

    写到这里已经解决了多线程下缓存复写的问题了,但是在实际业务中我们缓存可能存在于多个地方并且不同的查询ID获取不同的数据库数据,那么无论是通过同步代码块还是同步锁都是锁住是一块逻辑,在当前这个线程释放前其他任何的线程都无法访问,这时的性能肯定会大大下降,例如:用户A请求的资源ID为1,用户B请求的资源ID为2;用户A线程率先进入同步锁块的代码,此时,用户B请求的线程就被阻塞了;这里从业务逻辑上来说用户B请求的线程不应该被阻塞,因为用户B查询的是资源ID为2的数据,并不会跟用户A线程请求的资源ID为1的数据冲突,但是是通过同步锁时我们无法将锁的细粒度更小化;这时我们就面临着一个问题:如何只锁住当前的资源而不是方法或代码块?

        我们应该实现一个可以根据当前的入参的资源ID为据点的同步锁,只有在访问同一ID或资源的时候才会阻塞线程,不同ID的时候还是按照正常情况执行以提高性能;

分段锁

   综述以上的情况这里需要引入一个锁概念:分段锁

  此锁实现的思路来自于JDK7下的ConcurrentHashMap实现原理,JDK7下的ConcurrentHashMap利用了Fragment的概念来实现的:ConcurrentHashMap并发安全的实现原理~java7_hashmap为什么1.7要用分段锁保证并发-CSDN博客        

  我们来看代码的实现

**
 * 并发分段锁
 * 原理是利用ConcurrentHashMap储存锁,ReentrantReadWriteLock实现读共享、写互斥
 * 在ReentrantReadWriteLock中存在两种锁的实现,读写和写锁、在多线程数据安全性情况下,读读并行、读写、写读互斥
 * 使用读锁可以大幅度提高多线程Read的并发性能,而在,写的情况由于考虑多线程数据安全问题,读锁加锁时会与写锁互斥、写锁加锁时也会与读锁互斥
 * 在Jvm中锁的细粒度默认最小只有对象锁、类锁,无论是类锁还是对象锁最小原子性都只能针对某个对象内进行加锁,如果,多个线程根据不同的ID查询不同数据库数据时,如果使用对象锁那么就会导致先来线程先获取锁
 * 没有释放锁那么后续的多个线程将会阻塞,但是,从逻辑上来说多个线程只有在查询同一ID时才需要阻塞;这里通过ConcurrentHashMap来存储Lock对象从而达到锁住ID的效果
 * 通过ConcurrentHashMap来存储Lock对象,K为当前锁的对象,这里为取K的Hashcode ^ Hashcode >>> 16,高16低16位降低哈希冲突,提高锁的分布
 * ConcurrentHashMap本身是通过Cas和Sync來保证多线程下的数据安全,避免链表闭环
 * 在Jdk1.8之后ConcurrentHashMap的实现换成Cas和Sync,而在Jdk1.7时使用的则是Segment的数据结构来实现,在1.7中在解决多线程数据安全问题则是通过分段来实现
 *
 * @Author: Song L.Lu
 * @Since: 2023-06-12 14:13
 **/
public class ConcurrentReadWriteLock<K> extends ConcurrentHashMap<Integer, ReentrantReadWriteLock> {

    public ConcurrentReadWriteLock() {
        // 初始化HashMap容量为16
        super(16);
    }

    /**
     * 获取读锁
     *
     * @param k
     */
    public void readLock(K k) {
        Assert.notNull(k, "Lock key must not be null");
        ReentrantReadWriteLock lock = acquireNx(k);
        lock.readLock().lock();
    }

    /**
     * 释放读锁
     *
     * @param k
     */
    public void releaseReadLock(K k) {
        Assert.notNull(k, "Lock key must not be null");
        try {
            ReentrantReadWriteLock lock = acquire(k);
            if (exists(k))
                if (lock.getReadHoldCount() > 0)
                    lock.readLock().unlock();
        } finally {
            release(k);
        }

    }

    /**
     * 获取写入锁
     *
     * @param k
     */
    public void writeLock(K k) {
        Assert.notNull(k, "Lock key must not be null");
        ReentrantReadWriteLock lock = acquireNx(k);
        lock.writeLock().lock();
    }

    /**
     * 释放写入锁
     *
     * @param k
     */
    public void releaseWriteLock(K k) {
        Assert.notNull(k, "Lock key must not be null");
        try {
            ReentrantReadWriteLock lock = acquire(k);
            if (exists(k))
                lock.writeLock().unlock();
        } finally {
            release(k);
        }
    }

    /**
     * 尝试从HashMap获取已经存在的所对象
     * 如果,所不存在则将创建新的锁放入HashMap
     *
     * @param k 需要锁住的对象,本质是取的时该对象中的Hashcode
     * @return
     */
    private ReentrantReadWriteLock acquireNx(K k) {
        int hashcode = hashcode(k);
        // 尝试从HashMap获取锁对象
        ReentrantReadWriteLock lock = get(hashcode);
        if (Objects.isNull(lock)) { // 如果没有锁对象则创建一个新的锁对象
            try {
                // 这里使用ConcurrentHashMap作为锁对象的存储结构,避免,在多线程环境带来的数据安全性问题
                putIfAbsent(hashcode, new ReentrantReadWriteLock());
                lock = get(hashcode);
            } catch (Throwable t) {
                release(k); // 避免死锁
            }
        }
        return lock;
    }

    /**
     * 尝试从HashMap获取已经存在的所对象
     *
     * @param k 需要锁住的对象,本质是取的时该对象中的Hashcode
     * @return
     */
    private ReentrantReadWriteLock acquire(K k) {
        // 尝试从HashMap获取锁对象
        int hashcode = hashcode(k);
        return get(hashcode);
    }

    /**
     * 释放锁,从HashMap移除该锁
     *
     * @param k
     */
    private void release(K k) {
        remove((hashcode(k)));
    }

    /**
     * 锁对象是否存在
     *
     * @param k
     * @return
     */
    private boolean exists(K k) {
        return containsKey(hashcode(k));
    }

    /**
     * 生产锁对象的Hashcode
     * 取出当前对象的Hashcode,通过>>>无符号右移16位,将该对象的Hashcode的高16位和低16位进行或异运算;降低Hash冲突
     *
     * @param k
     * @return
     */
    private int hashcode(K k) {
        int hashcode = k.hashCode();
        return k.hashCode() ^ (hashcode >>> 16);
    }
}

其原理就是利用Map存储当前的锁实现,Map的K为资源ID,V为同步锁;

结束语

        当然这里还是有需要优化的情况,例如:如何维护当前的锁对象,需不需要使用对象池的方式来维护锁?如果使用了对象池那么内存不足是否使用内存淘汰策略来保证对象择优?

标签:缓存,分段,对象,lock,hashcode,同步,线程,ID
From: https://www.cnblogs.com/Tegra/p/18005934

相关文章

  • 本地缓存Ehcache的应用实践 | 京东云技术团队
    java本地缓存包含多个框架,其中常用的包括:Caffeine、GuavaCache和Ehcache,其中Caffeine号称本地缓存之王,也是近年来被众多程序员推崇的缓存框架,同时也是SpringBoot内置的本地缓存实现。但是除了Caffeine之外,还有一款也不错的本地缓存框架Ehcache,具有快速、灵活,并支持内存和磁盘缓......
  • "与事件处理程序不同,事件处理程序只在每次交互时运行一次,而 Effect 则在需要进行同步
    "与事件处理程序不同,事件处理程序只在每次交互时运行一次,而Effect则在需要进行同步时运行。"但是交互往往会同时触发事件处理,从而引起值变化,进而导致同步,从而运行Effect,不是吗?那么如何确定方法应该写在事件处理里还是Effect里面??事件处理程序(EventHandler)和React中的Effect(......
  • kettle从入门到精通 第三十九课 kettle 增量同步(日级)
     1、上一课我们学习了在数据量大的情况下的分页全量同步示例,本次我们一起学习下kettle增量全量同步。有些业务场景不需要实时数据,比如统计t-1日的销售业绩情况等。  2、kettle增量全量同步示例依然基于test数据库,从t1表增量同步数据到t2表,假定每天0点跑批将t1表中的t-1日......
  • c++加速cin和关闭同步流
    signedmain(){ios::sync_with_stdio(0);cin.tie(0),cout.tie(0);intT=1;//cin>>T;while(T--)solve();return0;}一·ios::sync_with_stdio(false);01"c++是否兼容stdio(c)"的开关函数02默认参数为true:将输出流绑到一起保证......
  • 金蝶云星空业务对象同步更新问题
     一、协同开发平台允许:多应用多个数据中心,多应用一个数据中心,一个应用多个数据中心。 二、在不同的应用下可以使用 【同步业务对象到数据中心】功能:该功能就是拉取当前应用在svn管理的最新版本更新到当前的数据中心。   【更新数据中心业务对象到应用......
  • 缓存预热是指在 Spring Boot 项目启动时
    缓存预热是指在SpringBoot项目启动时,预先将数据加载到缓存系统(如Redis)中的一种机制。那么问题来了,在SpringBoot项目启动之后,在什么时候?在哪里可以将数据加载到缓存系统呢?实现方案概述在SpringBoot启动之后,可以通过以下手段实现缓存预热:使用启动监听事件实现缓存预......
  • kettle从入门到精通 第三十八课 kettle 分页全量同步(数据量大)
    1、上一课我们学习了在数据量小的情况下的全量同步示例,本次我们一起学习下kettle分页全量同步。2、kettle分页全量同步示例依然基于test数据库,从t1表全量同步数据到t2表,由于t1表的数据比较大,所以选择分页全量同步策略,如下图所示。前提:a、基于mysql数据库b、分页查询数据基......
  • 如何通过ETL实现快速同步美团订单信息
    一、美团外卖现状美团作为中国领先的生活服务电子商务平台,其旗下的美团外卖每天承载着大量的订单信息。这些订单信息需要及时入库、清洗和同步,但由于数据量庞大且来源多样化,传统的手动处理方式效率低下,容易出错。比如,不同渠道的数据格式不一致,需要进行数据清洗和格式转换;数据量大......
  • 使用C# asp.net core 同步数据库
    代码片段:文末附链接。usingDataSync.Core;usingFurion.Logging.Extensions;usingMicrosoft.Data.SqlClient;usingMicrosoft.Extensions.Logging;usingSystem.Data;namespaceDataSync.Application.DataSync.Services{publicclassDataSyncServices:IDataSyn......
  • 2024年哪款便签软件是手机电脑同步的?
    在繁忙的生活、工作和学习中,我们时常面临各种琐事和任务,需要随时记录、提醒,以保持高效的生活节奏。比如,突然想到的灵感、重要的工作计划、紧急的购物清单,都需要一个便利的便签·工具来随手记录。特别是在多终端使用的情境下,如何实现手机、电脑同步成为了我们选择便签软件的关键需......