druid连接池connection holder is null分析

connection holder is null

异常背景

  • 第一次发生是在圣诞节加班冒烟自测需求时曾发生过该异常,当时排查过可能是由于某个地方事务过长造成的,恰好我又在冒烟新增的接口,就去看了一遍,发现确实方法链路较长,且整个接口都处于事务中,我便将需要事务的逻辑单独抽出,重新测了一遍,发现该异常没有发生了。便不了了之。
  • 第二次是在上线当天,测试环境出现大量异常,并导致接口成功率降至30%,排查无果,最后觉得是自动化测试调用频繁的原因,解决后便没有出现异常,开始上线。
  • 由于上线当天出现了其他的bug,注意力全部都集中在bug排查修复上,凌晨四点半又开始发生异常,便开始重新review此次版本新增代码,发现了XX编辑接口,手动开启事务后紧接着抛出异常,觉得可能是该原因,不过该接口只调用了一次,想不通为什么会造成几十几百次异常。
  • 在测试环境重复点击该异常(由于点击频率过高,200个事务堵塞了200条tomcat线程,服务器直接503)
  • 排查进度缓慢,也快6点,客户也快要上班了,便先回滚代码,后续再说,先初步怀疑是这个XX编辑手动开启事务的坑。

异常特点

  • 隐蔽性强
  • 触发点不明确
  • 复现难度高

初步分析

看看本次异常先是由哪里抛出

image
image

DruidPooledConnection是一个静态代理,持有ConnectionHolder, connection Holder里持有具体的connection对象, 可以看到在数据源连接在执行druidPooledConnection的所有和数据库相关方法时,都会先调用checkState()判断connection holder是否为null,如果是null就抛connection holder is null的异常。

holder是怎么被置为null的?

走一遍durid连接池源码就清楚了

深度分析

druid连接池源码追踪

先看看druid连接池各个参数代表的意思

druid连接池参数

配置 说明
initialSize 初始化时建立物理连接的个数。初始化发生在显示调用init方法,或者第一次getConnection时
maxActive 最大连接池数量
minIdle 最小连接池数量
maxWait 获取连接时最大等待时间,单位毫秒。配置了maxWait之后,缺省启用公平锁,并发效率会有所下降,如果需要可以通过配置useUnfairLock属性为true使用非公平锁。
<font color = "red">removeAbandoned </font> 开启线程持有连接超时移除
removeAbandonedTimeout 线程持有连接超过多长时间将会移除,默认300000,5分钟
poolPreparedStatements 是否缓存preparedStatement,也就是PSCache。PSCache对支持游标的数据库性能提升巨大,比如说oracle。在mysql下建议关闭。
maxOpenPreparedStatements 要启用PSCache,必须配置大于0,当大于0时,poolPreparedStatements自动触发修改为true。在Druid中,不会存在Oracle下PSCache占用内存过多的问题,可以把这个数值配置大一些,比如说100
validationQuery 用来检测连接是否有效的sql,要求是一个查询语句。如果validationQuery为null,testOnBorrow、testOnReturn、testWhileIdle都不会其作用。
testOnBorrow 申请连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。
testOnReturn 归还连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能
testWhileIdle 建议配置为true,不影响性能,并且保证安全性。申请连接的时候检测,如果空闲时间大于timeBetweenEvictionRunsMillis,执行validationQuery检测连接是否有效。
timeBetweenEvictionRunsMillis 有两个含义:1) Destroy线程会检测连接的间隔时间2) testWhileIdle的判断依据,详细看testWhileIdle属性的说明
connectionInitSqls 物理连接初始化的时候执行的sql
exceptionSorter 当数据库抛出一些不可恢复的异常时,抛弃连接
filters 属性类型是字符串,通过别名的方式配置扩展插件,常用的插件有:监控统计用的filter:stat日志用的filter:log4j防御sql注入的filter:wall
proxyFilters 类型是List<com.alibaba.druid.filter.Filter>,如果同时配置了filters和proxyFilters,是组合关系,并非替换关系

看完参数配置详解后,注意两个参数removeAbandoned和removeAbandonedTimeout,没错,这就是与这次异常息息相关的参数配置。

本次引发异常的configuration服务,removeAbandoned参数为true,removeAbandonedTimeout未配置(不清楚未配置原因),不配置默认五分钟超时

先进入连接池的初始化方法

只看主要逻辑即可

连接池的初始化方法

/** 连接池初始化 */

    public void init() throws SQLException {

        /** 如果已经初始化直接返回 */

        if (inited) {

            return;

        }



        final ReentrantLock lock = this.lock;

        try {

            /*** 加锁处理 */

            lock.lockInterruptibly();

        } catch (InterruptedException e) {

            throw new SQLException("interrupt", e);

        }

        try {

            /** 1.创建数据源ID */

            this.id = DruidDriver.createDataSourceId();



            /** 2.初始化过滤器 */

            for (Filter filter : filters) {

                filter.init(this);

            }



            /**

             * 省略

             * 3.maxActive、maxActive、minIdle、initialSize等参数校验以及JDBC等对象初始化

             * */



            /** 4.初始化连接数组,数组大小为最大连接数*/

            connections = new DruidConnectionHolder[maxActive];



            SQLException connectError = null;



            /** 5.根据初始化大小,初始化数据库连接*/

            for (int i = 0, size = getInitialSize(); i < size; ++i) {

                //1.创建连接

                Connection conn = createPhysicalConnection();

                //2.将连接封装成DruidConnectionHolder对象

                DruidConnectionHolder holder = new DruidConnectionHolder(this, conn);

                //3.将连接添加到连接数组中

                connections[poolingCount] = holder;

                incrementPoolingCount();//连接池中连接数自增+1

            }



            /** 创建并开启日志线程 */

            createAndLogThread();

            /** 创建并开启创建连接线程*/

            createAndStartCreatorThread();

            /** 创建并开启销毁连接线程*/

            createAndStartDestroyThread();

            /** 等待 创建连接线程 和 销毁连接线程 全部开启才算初始化完成 */

            initedLatch.await();



        }finally {

            /** 标记已经初始化*/

            inited = true;

            /** 释放锁*/

            lock.unlock();

        }

    }
  • 连接池初始化的逻辑主要如下
  1. 判断是否已经初始化,如果已经初始化直接跳出;如果没有初始化则继续初始化
  2. 防止并发初始化需要加锁处理
  3. 初始化过滤器并进行初始化参数校验
  4. 初始化连接数组,并根据配置的初始化大小创建指定数量的连接存入数组中,初始化的连接数就是传入的参数值initialSIze的值
  5. 创建并开启创建连接和销毁连接的线程
  6. 标记初始化完成并释放锁

连接摧毁过程

这里着重看createAndStartDestroyThread方法,毕竟我们的问题是conn holder读到了null,可能就是这个holder已经走了销毁程序,并且又放回了线程池中

image

销毁连接的任务交给了DestroyTask来实现,逻辑如下:

image

好了,从现在开始,关联到了上面所说的removeAbandoned参数了

销毁连接的任务主要有两个核心逻辑

  1. 销毁空闲连接 shrink方法

当一个连接长时间没有被使用,如果不及时清理就会造成资源浪费,所以需要定时检查空闲时间过长的连接进行断开连接销毁

  1. 回收超时连接 removeAbandoned方法

当一个连接被一个线程长时间占有没有被归还,有可能是程序出故障了或是有漏洞导致迟迟没有归还连接,这样就可能会导致连接池中的连接不够用,所以需要定时检查霸占连接时间过长的线程,如果超过规定时间没有归还连接,则强制回收该连接。


image

我们只需要看回收超时方法即可

回收超时方法
public int removeAbandoned() {

        int removeCount = 0;

        long currrentNanos = System.nanoTime();

        List<DruidPooledConnection> abandonedList = new ArrayList();

        synchronized(this.activeConnections) {

            Iterator iter = this.activeConnections.keySet().iterator();



            while(iter.hasNext()) {

                DruidPooledConnection pooledConnection = (DruidPooledConnection)iter.next();

                if (!pooledConnection.isRunning()) {

                    long timeMillis = (currrentNanos - pooledConnection.getConnectedTimeNano()) / 1000000L;

                    if (timeMillis >= this.removeAbandonedTimeoutMillis) {

                        iter.remove();

                        pooledConnection.setTraceEnable(false);

                        abandonedList.add(pooledConnection);

                    }

                }

            }

        }



        if (abandonedList.size() > 0) {

            Iterator var5 = abandonedList.iterator();



            while(true) {

                DruidPooledConnection pooledConnection;

                do {

                    while(true) {

                        if (!var5.hasNext()) {

                            return removeCount;

                        }



                        pooledConnection = (DruidPooledConnection)var5.next();

                        synchronized(pooledConnection) {

                            if (!pooledConnection.isDisable()) {

                                break;

                            }

                        }

                    }



                    JdbcUtils.close(pooledConnection);

                    pooledConnection.abandond();

                    ++this.removeAbandonedCount;

                    ++removeCount;

                } while(!this.isLogAbandoned());



            }

        } else {

            return removeCount;

        }

    }
image

回收超时连接方法的主要逻辑

  1. 定义需要回收的连接列表
  2. 遍历判断超时未回收的连接,并加入列表中
  3. 遍历回收连接列表,进行连接回收,强制断开连接
  4. JdbcUtils.close(pooledConnection)最终调向了recycle()把holder置为null

由上可知数据源连接在销毁后并没有放回连接池,只是将holder和conn置为null

异常出现可能原因

由上面分析知道 只要连接超过默认5分钟时间 就会被置为null 那就会有两种导致异常的可能

  1. 执行链路过长,连接在执行sql的时候会检查holder是否为null,那是不是一整条链路执行时间过长超出了5分钟,导致下一次执行的时候报conn holder is null?

不太可能,排查过所有的执行链路时长都没有超过五分钟的

  1. 系统中事务长时间未提交,若事务在五分钟后提交也会导致holder is null

确实本次经过代码提交排查也看到了有一处手动事务后未提交就抛出异常的,导致该事务一直处于未提交状态

不过当时在想,这个请求频率不高,应用重启后只调用了一次,怎么会报出几百条异常,系统中设置的最大活跃线程数为1000,即使我占用了一条并置为null,后续还有999条可用链接,并且durid连接池也不会使用到它。

还是先看看spring事务源码吧

spring事务管理原理

事务实现方式

在Spring中,事务有两种实现方式:

  1. 编程式事务管理: 编程式事务管理使用TransactionTemplate可实现更细粒度的事务控制。
  2. 申明式事务管理: 基于Spring AOP实现。其本质是对方法前后进行拦截,然后在目标方法开始之前创建或者加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。申明式事务管理不需要入侵代码,通过@Transactional就可以进行事务操作,方便快捷,且不会出错。

其实不管是编程式事务还是申明式事务,最终调用的底层核心代码是一致的。

为了贴合此次异常,此次异常的原因之一是由手动事务造成,所以只追踪编程式事务源码

编程式事务,spring提供了模板类TransactionTemplate

TransactionTemplate事务模板

image

重点:

TransactionTemplate实现了TransactionOperations中的execute()方法

事务模板execute方法解析
public <T> T execute(TransactionCallback<T> action) throws TransactionException {

// 内部封装好的事务管理器       

 if (this.transactionManager instanceof CallbackPreferringPlatformTransactionManager) {

            return ((CallbackPreferringPlatformTransactionManager)this.transactionManager).execute(this, action);

        } 

// 需要手动获取事务,执行方法,提交事务的管理器

else {

            // 1.获取事务状态

            TransactionStatus status = this.transactionManager.getTransaction(this);



            Object result;

            try {

                // 2.执行业务逻辑

                result = action.doInTransaction(status);

            } catch (RuntimeException var5) {

                // 应用运行时异常 -> 回滚

                this.rollbackOnException(status, var5);

                throw var5;

            } catch (Error var6) {

                // Error异常 -> 回滚

                this.rollbackOnException(status, var6);

                throw var6;

            } catch (Exception var7) {

                // 未知异常 -> 回滚

                this.rollbackOnException(status, var7);

                throw new UndeclaredThrowableException(var7, "TransactionCallback threw undeclared checked exception");

            }

            // 3.事务提交

            this.transactionManager.commit(status);

            return result;

        }

    }

一个完整的spring事务应该是遵循spring事务模板来完成

先开启事务,紧接着try住业务逻辑,在catch中回滚,在final中提交

spring事务核心类图

image

PlatformTransactionManager顶级接口定义了最核心的事务管理方法,下面一层是AbstractPlatformTransactionManager抽象类,实现了PlatformTransactionManager接口的方法并定义了一些抽象方法,供子类拓展。

DataSourceTransactionmanager,即JDBC单数据库事务管理器,基于Connection实现

image

恰好这次可能出问题的代码也有getTransaction方法,先看看这个里面做了什么先

getTransaction获取事务源码分析

AbstractPlatformTransactionManager实现了getTransaction()方法如下:

@Override

    public final TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException {

        Object transaction = doGetTransaction();



        // Cache debug flag to avoid repeated checks.

        boolean debugEnabled = logger.isDebugEnabled();



        if (definition == null) {

            // Use defaults if no transaction definition given.

            definition = new DefaultTransactionDefinition();

        }

      // 如果当前已经存在事务

        if (isExistingTransaction(transaction)) {

            // 根据不同传播机制不同处理

            return handleExistingTransaction(definition, transaction, debugEnabled);

        }



        // 超时不能小于默认值

        if (definition.getTimeout() < TransactionDefinition.TIMEOUT_DEFAULT) {

            throw new InvalidTimeoutException("Invalid transaction timeout", definition.getTimeout());

        }



        // 当前不存在事务,传播机制=MANDATORY(支持当前事务,没事务报错),报错

        if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_MANDATORY) {

            throw new IllegalTransactionStateException(

                    "No existing transaction found for transaction marked with propagation 'mandatory'");

        }// 当前不存在事务,传播机制=REQUIRED/REQUIRED_NEW/NESTED,这三种情况,需要新开启事务,且加上事务同步

        else if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRED ||

                definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW ||

                definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED) {

            SuspendedResourcesHolder suspendedResources = suspend(null);

            if (debugEnabled) {

                logger.debug("Creating new transaction with name [" + definition.getName() + "]: " + definition);

            }

            try {// 是否需要新开启同步// 开启// 开启

                boolean newSynchronization = (getTransactionSynchronization() != SYNCHRONIZATION_NEVER);

                DefaultTransactionStatus status = newTransactionStatus(

                        definition, transaction, true, newSynchronization, debugEnabled, suspendedResources);

                doBegin(transaction, definition);// 开启新事务

                prepareSynchronization(status, definition);//预备同步

                return status;

            }

            catch (RuntimeException ex) {

                resume(null, suspendedResources);

                throw ex;

            }

            catch (Error err) {

                resume(null, suspendedResources);

                throw err;

            }

        }

        else {

            // 当前不存在事务当前不存在事务,且传播机制=PROPAGATION_SUPPORTS/PROPAGATION_NOT_SUPPORTED/PROPAGATION_NEVER,这三种情况,创建“空”事务:没有实际事务,但可能是同步。警告:定义了隔离级别,但并没有真实的事务初始化,隔离级别被忽略有隔离级别但是并没有定义实际的事务初始化,有隔离级别但是并没有定义实际的事务初始化,

            if (definition.getIsolationLevel() != TransactionDefinition.ISOLATION_DEFAULT && logger.isWarnEnabled()) {

                logger.warn("Custom isolation level specified but no actual transaction initiated; " +

                        "isolation level will effectively be ignored: " + definition);

            }

            boolean newSynchronization = (getTransactionSynchronization() == SYNCHRONIZATION_ALWAYS);

            return prepareTransactionStatus(definition, null, true, newSynchronization, debugEnabled, null);

        }

    }

该方法走向两个分支

  1. 当前已存在事务:isExistingTransaction()判断是否存在事务,存在事务handleExistingTransaction()根据不同传播机制不同处理
  2. 当前不存在事务: 不同传播机制不同处理
doGetTransaction

进入第一行的doGetTransaction方法 跳转到DataSourceTransactionManager的实现

image
getResource

这也有个ConnectionHolder,先进去看看getResource方法做了什么

image
image
spring事务缓存数据源连接

原来是从map中获取的ConnectionHolder

spring事务中有多个map

image

第一个resources会缓存两种数据对

  1. 会话工厂和会话k=SqlsessionFactory v=SqlSessionHolder
  2. 数据源和连接k=DataSource v=ConnectionHolder

此刻就明白了,当该线程中还开启着事务,就会一直在threadlocal map中缓存一份数据源连接

当下次进来的时候则直接从缓存中拿连接。

那么我要是想重新开个事务呢?会重新往数据源拿连接不?

新建事务dobegin

进入dobegin

@Override

    protected void doBegin(Object transaction, TransactionDefinition definition) {

        DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;

        Connection con = null;



        try {// 如果事务还没有connection或者connection在事务同步状态,重置新的connectionHolder

            if (!txObject.hasConnectionHolder() ||

                    txObject.getConnectionHolder().isSynchronizedWithTransaction()) {

                Connection newCon = this.dataSource.getConnection();

                if (logger.isDebugEnabled()) {

                    logger.debug("Acquired Connection [" + newCon + "] for JDBC transaction");

                }// 重置新的connectionHolder

                txObject.setConnectionHolder(new ConnectionHolder(newCon), true);

            }

       //设置新的连接为事务同步中

            txObject.getConnectionHolder().setSynchronizedWithTransaction(true);

            con = txObject.getConnectionHolder().getConnection();

         //conn设置事务隔离级别,只读

            Integer previousIsolationLevel = DataSourceUtils.prepareConnectionForTransaction(con, definition);

            txObject.setPreviousIsolationLevel(previousIsolationLevel);//DataSourceTransactionObject设置事务隔离级别



            // 如果是自动提交切换到手动提交

            if (con.getAutoCommit()) {

                txObject.setMustRestoreAutoCommit(true);

                if (logger.isDebugEnabled()) {

                    logger.debug("Switching JDBC Connection [" + con + "] to manual commit");

                }

                con.setAutoCommit(false);

            }

       // 如果只读,执行sql设置事务只读

            prepareTransactionalConnection(con, definition);

            txObject.getConnectionHolder().setTransactionActive(true);// 设置connection持有者的事务开启状态



            int timeout = determineTimeout(definition);

            if (timeout != TransactionDefinition.TIMEOUT_DEFAULT) {

                txObject.getConnectionHolder().setTimeoutInSeconds(timeout);// 设置超时秒数

            }



            // 绑定connection持有者到当前线程

            if (txObject.isNewConnectionHolder()) {

                TransactionSynchronizationManager.bindResource(getDataSource(), txObject.getConnectionHolder());

            }

        }



        catch (Throwable ex) {

            if (txObject.isNewConnectionHolder()) {

                DataSourceUtils.releaseConnection(con, this.dataSource);

                txObject.setConnectionHolder(null, false);

            }

            throw new CannotCreateTransactionException("Could not open JDBC Connection for transaction", ex);

        }

    }

由该方法可见,若线程缓存中还有该数据源连接,就会直接使用这个连接holder(该holder是spring事务的连接holder,不是durid连接池的holder)

若该事务连接holder的connection所关联的druid 的连接holder为null,在执行sql时触发的checkstate方法会抛出 connection holder is null 异常

通过kibana查看日志确定手动事务开启和后续触发的异常都是同应用线程

image
image

至此排查完毕

源码追踪总结

手动开启事务后,获取了durid连接池中的某条数据源连接,放入了spring事务的本次线程缓存中,由于中途抛出异常,提前终止了代码,此次事务并没有提交或者回滚,在这个应用线程内一直缓存着这份数据源连接,五分钟后,durid连接被置为null,其他请求通过此应用线程执行时,看到了数据源连接还在缓存中,便不会去从druid连接池中获取,在执行sql时检查conn状态则会抛出connection holder is null异常。

durid开源作者温绍锦说过:不要试图缓存你从连接池中获取的连接

可是spring事务缓存了此份连接[泣不成声]

不过只要做好事务处理,就不会发生该异常

  1. 将事务交由spring控制
  2. 手动控制时,遵循事务模板

经验总结

  • 重复出现异常情况时,一定要排查清楚根本原因并解决后再上线
  • 针对外部原因影响,先消除外部因素,多复现问题
  • 多从源码角度分析问题产生原因
  • 多review自身代码是否合理
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容