标签 Cache 下的文章

Hibernate SQLQuery缓存问题分析


前情提要

系统的ORM框架是Hibernate4.1.9, Cache Provider是Ehcache, 设置CacheRegion后遭遇到缓存不失效问题.
比如说: 使用SQL语句查询一个表的数目是10000, 这时候删除掉了该表的一条记录, 那么再次使用同一条语句查询数目时应该重新去数据库查询得到结果为9999, 但是因为缓存没有失效, 所以查出来的还是10000.
但是, Hibernate从古老的3.1版本直到最新的5.3.9都有这个问题, 就很emmmm

原因分析

这个主要的原因是Hibernate在处理SQLQuery时, 没有正确设置querySpaces, 也就是本次查询关联的表名.
如果检测到查询的关联表已经被修改, 那么这个缓存应该立即失效, 由于没有设置querySpaces导致任意SQLQuery都不会被这个机制所影响.

通过Hibernate的查询最后会到org.hibernate.loader.Loader类下的listUsingQueryCache中, 这里会尝试中从Cache中获取数据, 如果Cache中存在数据就返回Cache中的数据:

private List listUsingQueryCache(
        final SessionImplementor session,
        final QueryParameters queryParameters,
        final Set querySpaces,
        final Type[] resultTypes) {

    QueryCache queryCache = factory.getQueryCache( queryParameters.getCacheRegion() );

    QueryKey key = generateQueryKey( session, queryParameters );

    if ( querySpaces == null || querySpaces.size() == 0 )
        LOG.tracev( "Unexpected querySpaces is {0}", ( querySpaces == null ? querySpaces : "empty" ) );
    else {
        LOG.tracev( "querySpaces is {0}", querySpaces );
    }

    List result = getResultFromQueryCache(
            session,
            queryParameters,
            querySpaces,
            resultTypes,
            queryCache,
            key
        );

    if ( result == null ) {
        result = doList( session, queryParameters, key.getResultTransformer() );

        putResultInQueryCache(
                session,
                queryParameters,
                resultTypes,
                queryCache,
                key,
                result
        );
    }
    // ...
}

跟踪getResultFromQueryCache方法, 会发现他最终是从org.hibernate.cache.internal.StandardQueryCacheget方法里获取的数据:

public List get(
        QueryKey key,
        Type[] returnTypes,
        boolean isNaturalKeyLookup,
        Set spaces,
        SessionImplementor session) throws HibernateException {
    LOG.debugf( "Checking cached query results in region: %s", cacheRegion.getName() );

    List cacheable = (List) cacheRegion.get( key );
    logCachedResultDetails( key, spaces, returnTypes, cacheable );

    if ( cacheable == null ) {
        LOG.debug( "Query results were not found in cache" );
        return null;
    }

    Long timestamp = (Long) cacheable.get( 0 );
    if ( !isNaturalKeyLookup && !isUpToDate( spaces, timestamp ) ) {
        LOG.debug( "Cached query results were not up-to-date" );
        return null;
    }
    // ...

这里会在isUpToDate方法里检测数据是否已经失效, 该方法的实现在org.hibernate.cache.spi.UpdateTimestampsCache:

public boolean isUpToDate(Set spaces, Long timestamp) throws HibernateException {
    final boolean debug = LOG.isDebugEnabled();
    final boolean stats = factory != null && factory.getStatistics().isStatisticsEnabled();

    for ( Serializable space : (Set<Serializable>) spaces ) {
        Long lastUpdate = (Long) region.get( space );
        if ( lastUpdate == null ) {
            if ( stats ) {
                factory.getStatisticsImplementor().updateTimestampsCacheMiss();
            }
            //the last update timestamp was lost from the cache
            //(or there were no updates since startup!)
            //updateTimestamps.put( space, new Long( updateTimestamps.nextTimestamp() ) );
            //result = false; // safer
        }
        else {
            if ( debug ) {
                LOG.debugf(
                        "[%s] last update timestamp: %s",
                        space,
                        lastUpdate + ", result set timestamp: " + timestamp
                );
            }
            if ( stats ) {
                factory.getStatisticsImplementor().updateTimestampsCacheHit();
            }
            if ( lastUpdate >= timestamp ) {
                return false;
            }
        }
    }
    return true;
}

如果发现上次的更新时间lastUpdate比缓存的时间timestamp更新则会更新缓存, 但是因为SQLQuery不会设置querySpaces, 所以根本连这个循环都进不去= =

关于querySpaces的值的设置, 根据跟踪主要是在org.hibernate.loader.custom.sql.SQLCustomQuery中设置的:

    public SQLCustomQuery(
            final String sqlQuery,
            final NativeSQLQueryReturn[] queryReturns,
            final Collection additionalQuerySpaces,
            final SessionFactoryImplementor factory) throws HibernateException {

        LOG.tracev( "Starting processing of sql query [{0}]", sqlQuery );
        SQLQueryReturnProcessor processor = new SQLQueryReturnProcessor(queryReturns, factory);
        SQLQueryReturnProcessor.ResultAliasContext aliasContext = processor.process();


//        Map[] propertyResultMaps =  (Map[]) processor.getPropertyResults().toArray( new Map[0] );
//        Map[] collectionResultMaps =  (Map[]) processor.getCollectionPropertyResults().toArray( new Map[0] );
//
//        List collectionSuffixes = new ArrayList();
//        List collectionOwnerAliases = processor.getCollectionOwnerAliases();
//        List collectionPersisters = processor.getCollectionPersisters();
//        int size = collectionPersisters.size();
//        if (size!=0) {
//            collectionOwners = new int[size];
//            collectionRoles = new String[size];
//            //collectionDescriptors = new CollectionAliases[size];
//            for ( int i=0; i<size; i++ ) {
//                CollectionPersister collectionPersister = (CollectionPersister) collectionPersisters.get(i);
//                collectionRoles[i] = ( collectionPersister ).getRole();
//                collectionOwners[i] = processor.getAliases().indexOf( collectionOwnerAliases.get(i) );
//                String suffix = i + "__";
//                collectionSuffixes.add(suffix);
//                //collectionDescriptors[i] = new GeneratedCollectionAliases( collectionResultMaps[i], collectionPersister, suffix );
//            }
//        }
//        else {
//            collectionRoles = null;
//            //collectionDescriptors = null;
//            collectionOwners = null;
//        }
//
//        String[] aliases = ArrayHelper.toStringArray( processor.getAliases() );
//        String[] collAliases = ArrayHelper.toStringArray( processor.getCollectionAliases() );
//        String[] collSuffixes = ArrayHelper.toStringArray(collectionSuffixes);
//
//        SQLLoadable[] entityPersisters = (SQLLoadable[]) processor.getPersisters().toArray( new SQLLoadable[0] );
//        SQLLoadableCollection[] collPersisters = (SQLLoadableCollection[]) collectionPersisters.toArray( new SQLLoadableCollection[0] );
//        lockModes = (LockMode[]) processor.getLockModes().toArray( new LockMode[0] );
//
//        scalarColumnAliases = ArrayHelper.toStringArray( processor.getScalarColumnAliases() );
//        scalarTypes = ArrayHelper.toTypeArray( processor.getScalarTypes() );
//
//        // need to match the "sequence" of what we return. scalar first, entity last.
//        returnAliases = ArrayHelper.join(scalarColumnAliases, aliases);
//
//        String[] suffixes = BasicLoader.generateSuffixes(entityPersisters.length);

        SQLQueryParser parser = new SQLQueryParser( sqlQuery, new ParserContext( aliasContext ), factory );
        this.sql = parser.process();
        this.namedParameterBindPoints.putAll( parser.getNamedParameters() );

//        SQLQueryParser parser = new SQLQueryParser(
//                sqlQuery,
//                processor.getAlias2Persister(),
//                processor.getAlias2Return(),
//                aliases,
//                collAliases,
//                collPersisters,
//                suffixes,
//                collSuffixes
//        );
//
//        sql = parser.process();
//
//        namedParameterBindPoints = parser.getNamedParameters();


        customQueryReturns.addAll( processor.generateCustomReturns( parser.queryHasAliases() ) );

//        // Populate entityNames, entityDescrptors and querySpaces
//        entityNames = new String[entityPersisters.length];
//        entityDescriptors = new EntityAliases[entityPersisters.length];
//        for (int i = 0; i < entityPersisters.length; i++) {
//            SQLLoadable persister = entityPersisters[i];
//            //alias2Persister.put( aliases[i], persister );
//            //TODO: Does not consider any other tables referenced in the query
//            ArrayHelper.addAll( querySpaces, persister.getQuerySpaces() );
//            entityNames[i] = persister.getEntityName();
//            if ( parser.queryHasAliases() ) {
//                entityDescriptors[i] = new DefaultEntityAliases(
//                        propertyResultMaps[i],
//                        entityPersisters[i],
//                        suffixes[i]
//                    );
//            }
//            else {
//                entityDescriptors[i] = new ColumnEntityAliases(
//                        propertyResultMaps[i],
//                        entityPersisters[i],
//                        suffixes[i]
//                    );
//            }
//        }
        if ( additionalQuerySpaces != null ) {
            querySpaces.addAll( additionalQuerySpaces );
        }

//        if (size!=0) {
//            collectionDescriptors = new CollectionAliases[size];
//            for ( int i=0; i<size; i++ ) {
//                CollectionPersister collectionPersister = (CollectionPersister) collectionPersisters.get(i);
//                String suffix = i + "__";
//                if( parser.queryHasAliases() ) {
//                    collectionDescriptors[i] = new GeneratedCollectionAliases( collectionResultMaps[i], collectionPersister, suffix );
//                } else {
//                    collectionDescriptors[i] = new ColumnCollectionAliases( collectionResultMaps[i], (SQLLoadableCollection) collectionPersister );
//                }
//            }
//        }
//        else {
//            collectionDescriptors = null;
//        }
//
//
//        // Resolve owners
//        Map alias2OwnerAlias = processor.getAlias2OwnerAlias();
//        int[] ownersArray = new int[entityPersisters.length];
//        for ( int j=0; j < aliases.length; j++ ) {
//            String ownerAlias = (String) alias2OwnerAlias.get( aliases[j] );
//            if ( StringHelper.isNotEmpty(ownerAlias) ) {
//                ownersArray[j] =  processor.getAliases().indexOf( ownerAlias );
//            }
//            else {
//                ownersArray[j] = -1;
//            }
//        }
//        if ( ArrayHelper.isAllNegative(ownersArray) ) {
//            ownersArray = null;
//        }
//        this.entityOwners = ownersArray;

    }

可以看到这个构造函数的代码很长, 但大部分都被注释掉了, 和querySpaces有关的只有这一句querySpaces.addAll( additionalQuerySpaces );.
这里的additionalQuerySpaces是在建立查询时放入的一个参数, 可以手动指定querySpaces, 如果没有手动指定, 那么这里也是空的, 因此查询进行到Loader层的时候也会是空的.
可以看到这个方法中注释掉了大量代码, 其中也包括处理querySpaces的部分, 可能是原来写的有问题, 所以作者干脆注释掉算了= =
然后...直到最新版本, 也再也没有人动过这里的代码= =
所以说, 开源有好的地方, 但是也是有不好的地方的_(:3」∠)_

既然已经知道了问题, 那么有两种解决问题的思路:

  1. 重写SQLCustomQuery类, 将这里对querySpaces手动实现上去, 未来也许还可以提交一个PR

    • 这种方式的问题是, 单独对框架进行修改不利于日后的升级, 未来接手维护的人可能不太清楚你都改了那些内容, 会不会对业务有影响, 因此不敢轻易升级
  2. 不用Hibernate提供的缓存, 而是根据业务需要在Hibernate外手动建立一个针对业务的查询缓存

    • 这种方式感觉就是很不优雅= =

解决方法

业务层缓存

基本思路是实现一个单例, 然后使用ReadWriteLock对读写进行加锁以保证线程安全, 单例采用DCL实现:

public class CountCache{
    private volatile static CountCache instance;
    private Map<String, Long> cache = new HashMap<>();
    private Map<String, Long> cacheTime = new HashMap<>();

    // 加读写锁保证线程安全
    private ReadWriteLock rwLock = new ReentrantReadWriteLock();

    // 超时时间设置, 单位为秒
    private final static int timeout = 1800;

    // 防止直接创建实例
    private CountCache(){}

    /**
     * DCL形式的单例
     */
    public static CountCache getInstance(){
        if(instance == null){
            synchronized(CountCache.class){
                if(instance == null){
                    instance = new CountCache();
                }
            }
        }
        return instance;
    }

    /**
     * 读缓存内容, 如果写入时间超时, 则缓存无效
     */
    public long readCache(String key){
        try{
            if(rwLock.readLock().tryLock() || rwLock.readLock().tryLock(1, TimeUnit.SECONDS)){
                try{
                    if(cacheTime.containsKey(key)){
                        long interval = System.currentTimeMillis() - cacheTime.get(key);
                        // if timeout
                        if(interval / 1000 > timeout){
                            invalidateCache(key);
                            return -1;
                        }
                        // read cache
                        return cache.get(key);
                    }
                }finally{
                    rwLock.readLock().unlock();
                }
            }
        }catch(InterruptedException e){
            e.printStackTrace();
        }
        return -1;
    }

    /**
     * 写缓存内容, 记录写入时间
     */
    public void writeCache(String key, long value){
        try{
            if(rwLock.writeLock().tryLock() || rwLock.writeLock().tryLock(1, TimeUnit.SECONDS)){
                try{
                    cache.put(key, value);
                    cacheTime.put(key, System.currentTimeMillis());
                }finally{
                    rwLock.writeLock().unlock();
                }
            }
        }catch(InterruptedException e){
            e.printStackTrace();
        }
    }

    /**
     * 将缓存失效
     */
    public void invalidateCache(String key){
        try{
            if(rwLock.writeLock().tryLock() || rwLock.writeLock().tryLock(1, TimeUnit.SECONDS)){
                try{
                    cache.remove(key);
                    cacheTime.remove(key);
                }finally{
                    rwLock.writeLock().unlock();
                }
            }
        }catch(InterruptedException e){
            e.printStackTrace();
        }
    }
}

魔改SQLCustomQuery

有没有什么办法在不修改Hibernate的Jar包的前提下实现对SQLCustomQuery类的替换呢?
能不能在运行时, 卸载掉原来的SQLCustomQuery类然后加载我们自定义的SQLCustomQuery类, 让我们自定义的类替换掉原有的实现?

替换原有类

经过调研, 替换原有类主要有两种思路:

  1. 自定义一个自己的类加载器, 然后越早越好, 使用这个自定义的类加载器来加载所有的类, 而不是Tomcat自带的WebappClassLoader, 这样整个WEB应用都会使用我们自定义的类加载器了, 这样在我们的类加载器对单独几个特殊的类来做特殊的加载就很容易

    • 理论基础是: 一个类会用它自己的类加载器来加载它所new的对象, 所以只要在很早的时候加载一个启动类, 那么由这个类派生出来的所有类都会使用我们的类加载器了
    • 实现上的话, 我们可以定一个黑名单, 在黑名单里的类不委托WebappClassLoader加载, 而是我们自己加载, 而正常的类都交给WebappClassLoader来加载, 使用正常的双亲委托机制
    • 但是, 很难找到一个那么早的时机来使用自己的类加载器, 要是需要修改Tomcat源码的话...还不如直接修改Hibernate源码了
  2. 使用Instrument机制, 这样需要在Java虚拟机启动的时候带一个Agent, 在Agent里面我们要对JVM虚拟机做一些操作就很容易, 替换一个类更是可行的

    • 比如说, IDEA就会在启动程序的时候带上-javaagent:C:\App\JetBrains\apps\IDEA-U\ch-0\183.5912.21\lib\idea_rt.jar=5451:C:\App\JetBrains\apps\IDEA-U\ch-0\183.5912.21\bin参数
    • 但是, 这样操作的话需要修改Tomcat的Boostrap脚本, 协调起来比较麻烦...还不如直接修改Hibernate源码了

更加简单的方法

经过测试, 其实根本没必要进行上述那么复杂的操作, Tomcat会优先加载WEB-INF/classes中的类然后才去加载WEB-INF/libs下的类, 因此我们只要在自己的应用里实现一个同名的类就可以了.
这样, Tomcat会优先加载我们应用中的类, 而忽略掉Jar包中的类的加载.

修改后的SQLCustomQuery如下:

public class SQLCustomQuery implements CustomQuery{
    // 缓存数据库中的所有表名
    private static Set<String> allUserTables;

    public SQLCustomQuery(
            final String sqlQuery,
            final NativeSQLQueryReturn[] queryReturns,
            final Collection additionalQuerySpaces,
            final SessionFactoryImplementor factory) throws HibernateException{

        // 省略SQLCustomQuery原有代码

        // 一个简单的提取SQL所查询的表的思路是, 因为我们的业务表拥有固定的前缀
        // 那么只需要按空格分开所有Token, 然后只保留固定前缀开头的单词最后去重即可, 但是有极低的可能性误判
        // 更加靠谱的思路的是, 我们去获取我们所有表的列表, 所以只取已知表的单词就是所查询的表
        // 考虑到第一种和第二种方法实现起来难度差不多, 所以干脆实现第二种了
        LOG.infov("Loaded Custom SQLCustomQuery of SQL Query [{0}]", sql);

        // 初始化或更新所有表的列表
        try{
            // 初次使用需要初始化
            if(allUserTables == null){
                allUserTables = queryUserTables();
            }
            // 如果谁想不开在代码修改表结构的话, 更新缓存的表名
            String lowerSQL = sql.toLowerCase();
            if(lowerSQL.contains("table") && (lowerSQL.contains("create") || lowerSQL.contains("drop"))){
                allUserTables = queryUserTables();
                LOG.warnv("Create/Drop Table! Query: [{0}]", sql);
            }
        }catch(NamingException | SQLException e){
            e.printStackTrace();
        }

        // 提取SQL查询中的所有Token, 并记录包含表名的Token
        String[] sqlTokens = sql.split("  *");
        for(String token : sqlTokens){
            if(allUserTables.contains(token)){
                querySpaces.add(token);
            }
        }
    }

    /**
     * 这里采用JDBC直接查询数据库所有表名
     */
    private Set<String> queryUserTables() throws NamingException, SQLException{
        Context ctx = new InitialContext();
        String dbName = ApplicationContainer.sc.getAttribute("dataSource").toString().toLowerCase();
        DataSource ds = (DataSource)ctx.lookup("java:comp/env/" + dbName);

        Connection conn = null;
        Set<String> result = new HashSet<>();
        try{
            conn = ds.getConnection();
            Statement st = conn.createStatement();
            ResultSet rs = st.executeQuery("select table_name from user_tables"); // Oracle 数据库
            while(rs.next()){
                result.add(rs.getString(1));
            }
        }finally{
            if(conn != null && !conn.isClosed()){
                conn.close();
            }
        }

        return result;
    }

一个简单获取所关联的表名思路就是先获取所有的表名, 然后看每一个SQL中的Token是否是表名, 如果是则添加到querySpaces中.
如果真的要去解析SQL, 建立AST语法树的话, 那就太麻烦了_(:3」∠)_
直接正则表达式的话, 因为很难预料业务代码会写出什么样的SQL, 所以并不是特别靠谱.

直接采用JDBC查询的原因是, 这样的操作比较快, 而且只查询一次, 且在框架内部操作并不适合再通过Hibernate去查询.

由于allUserTables被重复设置也没什么问题, 所以不太需要考虑线程安全问题.

当然, 根据我们的实际业务需要, 这样来处理querySpaces没有太大问题, 但是如果作为框架内部代码显然不能这么实现, 到时候还是需要解析SQL, 处理AST然后获得相关联的表. 等我什么时候学了编译原理, 这里还没有人改我就写一个然后提个PR吧233

简单总结

没什么想说的_(:3」∠)_ 辣鸡Hibernate(x