MySQL Server-Side Cursor及OceanBase是否支持此功能

MySQL Server-Side Cursor及OceanBase是否支持此功能

背景與目的

從過往經驗,從Informix到Oracle到DB2,應用使用這些數據庫時,都會被強調:在語句中儘量使用變量參數,然後Prepare後可多次使用,減少數據庫Server端語句硬解析帶來的性能損耗,減少數據庫Server端語句Cache空間的佔用。

另外,不管是顯式聲明還是隱式產生,數據庫Server端都會爲Select語句(或者所有DML語句)一個Cursor(遊標),通過Fetch這個Cursor來獲取結果集。如果結果集小,一次就可全部取得整個結果集;如果結果集大,就需要多次交互,每次由Server端從庫中讀取一部分數據返回。每次獲取的記錄數,可通過參數控制,以在減少交互提高性能與客戶端內存資源耗用間權衡。比如Oracle,JDBC是通過fetchsize、OCI是通過OCIStmtFetch函數參數nrows、Pro*C是通過宿主變量數組大小,來控制每次從Server端獲取多少條記錄回來。

由於上面提到的Prepare與Cursor功能,都是在數據庫Server端實現的,所以可以被叫做Server-Side Prepared Statement與Server-Side Cursor。

最近因爲某些原因,需要了解下OceanBase分佈式數據庫,OB對外是兼容MySQL數據庫協議,也就看了下MySQL JDBC對Server-Side Prepared Statement和Server-Side Cursor的支持程度,以及OceanBase數據庫Server是否支持這兩個功能。

MySQL JDBC的實現

這部分內容的主要參考內容是MySQL Connector/J(JDBC) ReferenceJDBC源碼MySQL Server源碼

Server-Side Prepared Statement

MySQL JDBC實現了兩種Prepared Statement:

  • Client-Side Prepared Statement

    此種方式是由JDBC在客戶端模擬對Prepared Statment的功能,實際發往數據庫Server端的是已經將變量參數值代入後拼裝出來的SQL語句,變量參數值不同就是不同的SQL語句。

    這也是MySQL JDBC的默認使用的方式,官方解釋的原因是:

    default because early MySQL versions did not support the prepared statement feature or had problems with its implementation

  • Server-Side Prepared Statement

    爲了使用這種方式,需要設置屬性useServerPrepStmts的值爲true,通過:

讓我們來大致看一下JDBC源代碼中對Server-Side Prepared Statement的支持實現。

首先,在java.sql.Connection實現類com.mysql.jdbc.ConnectionImpl中方法prepareStatement():

public java.sql.PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
    synchronized (getConnectionMutex()) {
       ....

        if (this.useServerPreparedStmts && canServerPrepare) {
            if (this.getCachePreparedStatements()) {
                synchronized (this.serverSideStatementCache) {
                    ....
                    if (pStmt == null) {
                        try {
                            pStmt = ServerPreparedStatement.getInstance(getMultiHostSafeProxy(), nativeSql, this.database, resultSetType,
                                                                        resultSetConcurrency);
                           ...
                            pStmt.setResultSetType(resultSetType);
                            pStmt.setResultSetConcurrency(resultSetConcurrency);
                        } catch (SQLException sqlEx) {
                           ....
                }
            } else {
                try {
                    pStmt = ServerPreparedStatement.getInstance(getMultiHostSafeProxy(), nativeSql, this.database, resultSetType, resultSetConcurrency);

                    pStmt.setResultSetType(resultSetType);
                    pStmt.setResultSetConcurrency(resultSetConcurrency);
                } catch (SQLException sqlEx) {
                    ...
            }
        } else {
            pStmt = (PreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
        }

        return pStmt;
    }
}

從上面代碼可以看出,如果屬性useServerPreparedStmts爲true,且canServerPrepare也爲真,就會使用ServerPreparedStatement類來構造Statement供後續執行SQL使用。屬性useServerPreparedStmts是在哪裏設置的呢? 是在ConnectionImpl類的initializePropsFromServer()方法中:

if (getUseServerPreparedStmts() && versionMeetsMinimum(4, 1, 0)) {
    this.useServerPreparedStmts = true;

    if (versionMeetsMinimum(5, 0, 0) && !versionMeetsMinimum(5, 0, 3)) {
        this.useServerPreparedStmts = false; // 4.1.2+ style prepared
        // statements
        // don't work on these versions
    }
}

getUseServerPreparedStmts()方法讀取了連接屬性中的detectServerPreparedStmts屬性值:

public boolean getUseServerPreparedStmts() {
    return this.detectServerPreparedStmts.getValueAsBoolean();
}

屬性detectServerPreparedStmts在這裏被定義:

// Think really long and hard about changing the default for this many, many applications have come to be acustomed to the latency profile of preparing
// stuff client-side, rather than prepare (round-trip), execute (round-trip), close (round-trip).
private BooleanConnectionProperty detectServerPreparedStmts = new BooleanConnectionProperty("useServerPrepStmts", false,                                       Messages.getString("ConnectionProperties.useServerPrepStmts"), "3.1.0", MISC_CATEGORY, Integer.MIN_VALUE);

在JDBC URL裏添加useServerPrepStmts=true、或通過Propertiesu將useServerPrepStmts=true傳遞給DriverManager.getConnection方法、或調用MysqlDataSource的setUseServerPreparedStmts(true)後,在對URL的解析過程中,會調用setUseServerPreparedStmts方法設置這一屬性值爲true。

再來看下ServerPreparedStatement.getInstance()的實現,它直接調用了構造方法生成Statement類對象:

protected static ServerPreparedStatement getInstance(MySQLConnection conn, String sql, String catalog, int resultSetType, int resultSetConcurrency)
    throws SQLException {
    if (!Util.isJdbc4()) {
        return new ServerPreparedStatement(conn, sql, catalog, resultSetType, resultSetConcurrency);
    }

    try {
        return (ServerPreparedStatement) JDBC_4_SPS_CTOR
            .newInstance(new Object[] { conn, sql, catalog, Integer.valueOf(resultSetType), Integer.valueOf(resultSetConcurrency) });
    } catch ...
      ...
    }
}

構造函數會調用serverPrepare(sql)方法,在這個方法代碼裏有下面的代碼行:

Buffer prepareResultPacket = mysql.sendCommand(MysqlDefs.COM_PREPARE, sql, null, false, characterEncoding, 0);

此行代碼的意思就是,將sql語句封裝到MySQL數據包中發給數據庫Server,包類型爲 COM_STMT_PREPARE(MysqlDefs.COM_PREPARE與COM_STMT_PRPARES值相等:22),由數據庫Server完成語句的Prepare,並返回statement_id等信息給客戶端,客戶端後面將此id做爲COM_STMT_EXECUTE的參數來執行Prepare好的SQL,完成數據查詢或操作。

Server-Side Cursor

默認情況下,MySQL JDBC是將數據庫查詢的結果集,統統地全部收下來放到內存中。在查詢返回記錄條數不多的情況下,這樣做比較高效、性能好。 但當查詢結果包含大量記錄時,可能會把應用JVM內存撐爆掉。

MySQL官方文檔給指出了兩條路:

  • 讓JDBC Driver一條一條記錄地流式返回結果集內容
  • 使用Server-Side Cursor,即數據庫Server端遊標

第一種方式的激活方式是這樣的:

stmt = conn.createStatement(java.sql.ResultSet.TYPE_FORWARD_ONLY,
              java.sql.ResultSet.CONCUR_READ_ONLY);
stmt.setFetchSize(Integer.MIN_VALUE);

粗略翻了下實現代碼,感覺就是控制從網絡讀取返回數據的速度,一條一條記錄地讀,實際上Server端已經生成了整個的結果集要往網絡連接裏寫,但客戶端這邊在卡着小脖進行流量控制。也就理解了爲什麼官方文檔會說:

There are some caveats with this approach. You must read all of the rows in the result set (or close it) before you can issue any other queries on the connection, or an exception will be thrown.

白話點兒講就是:要麼你把所有數據全讀回來,要麼你中間放棄,否則在這個連接上你做不了其他任何事情。 象我們循環從一張表中取一條記錄再根據此記錄內容去更新其他表記錄的用法,在此種方式是行不通的。

讓我們繼續看下第二種方式吧,與Server-Side Prepared Statement一樣,打開這種方式有三種:

以上面任一種姿勢,設置useCursorFetch=true並使用,比如:

conn = DriverManager.getConnection("jdbc:mysql://localhost/?useCursorFetch=true", "user", "s3cr3t");
stmt = conn.createStatement();
stmt.setFetchSize(100);
rs = stmt.executeQuery("SELECT * FROM your_table_here");

屬性解析

這一屬性的解析路徑會沿着路徑:ConnectionImpl構造方法 => ConnectionImpl.initializeDriverProperties() => ConnectionImpl.initializeProperties() => ConnectionPropertiesImpl.postInitialization() 溜達到這段代碼:

if (getUseCursorFetch()) {
        // assume they want to use server-side prepared statements because they're required for this functionality
        setDetectServerPreparedStmts(true);
 }

在溜達的路上,已經將Connection的屬性useCursorFetch設置成了true,默認此屬性是false,即採用將結果集完全取回客戶端的默認方式:

private BooleanConnectionProperty useCursorFetch = new BooleanConnectionProperty("useCursorFetch", false,
            Messages.getString("ConnectionProperties.useCursorFetch"), "5.0.0", PERFORMANCE_CATEGORY, Integer.MAX_VALUE);

然後判斷當useCursorFetch爲true時,自動地調方法setDetectServerPreparedStmts()同時設置屬性useServerPrepStmts爲true,即打開Server-Side Prepared Statement功能,此功能正如[上面](#Server-Side Prepared Statement)說得,默認是關閉的,即採用Client-Side Prepared Statement:

// Think really long and hard about changing the default for this many, many applications have come to be acustomed to the latency profile of preparing
// stuff client-side, rather than prepare (round-trip), execute (round-trip), close (round-trip).
private BooleanConnectionProperty detectServerPreparedStmts = new BooleanConnectionProperty("useServerPrepStmts", false,                                       Messages.getString("ConnectionProperties.useServerPrepStmts"), "3.1.0", MISC_CATEGORY, Integer.MIN_VALUE);

由此可見,使用Server-Side Cursor功能時,必定要使用Service-Side Prepared Statement功能。

除此之外,還需要設置其他的一些屬性:

  • 設置fetchSize>0,控制每次從Server端取多少條記錄回來
  • resultSetType=ResultSet.TYPE_FORWARD_ONLY
  • resultSetConcurrency=ResultSet.CONCUR_READ_ONLY

因爲在這裏這裏 都在做條件檢查 :

private boolean useServerFetch() throws SQLException {
    synchronized (checkClosed().getConnectionMutex()) {
        return this.connection.isCursorFetchEnabled() && this.fetchSize > 0 && this.resultSetConcurrency == ResultSet.CONCUR_READ_ONLY
            && this.resultSetType == ResultSet.TYPE_FORWARD_ONLY;
    }
}
if (this.connection.versionMeetsMinimum(5, 0, 2) && this.connection.getUseCursorFetch() && isBinaryEncoded && callingStatement != null
    && callingStatement.getFetchSize() != 0 && callingStatement.getResultSetType() == ResultSet.TYPE_FORWARD_ONLY) {
    ServerPreparedStatement prepStmt = (com.mysql.jdbc.ServerPreparedStatement) callingStatement;

    boolean usingCursor = true;
    ...
}

實際使用時,只要設置useCursorFetch=true與fetchSize>0即可,因爲resultSetType、resultSetConcurrency兩個屬性值在ConnectionImpl的createStatement()、prepareStatement()方法中,都進行了默認設置:

private static final int DEFAULT_RESULT_SET_TYPE = ResultSet.TYPE_FORWARD_ONLY;
private static final int DEFAULT_RESULT_SET_CONCURRENCY = ResultSet.CONCUR_READ_ONLY;

public java.sql.Statement createStatement() throws SQLException {
    return createStatement(DEFAULT_RESULT_SET_TYPE, DEFAULT_RESULT_SET_CONCURRENCY);
}
public java.sql.PreparedStatement prepareStatement(String sql) throws SQLException {
    return prepareStatement(sql, DEFAULT_RESULT_SET_TYPE, DEFAULT_RESULT_SET_CONCURRENCY);
}

SQL語句執行

在獲取了連接並CreateStatement後,再看看Statement的executeQuery方法針對Server-Side Cursor(useServerFetch=true)做了哪些處理?

首先代碼中有下面這樣的判斷,如果是使用Server-Side Cursor,則去創建一個usingServerFetch的ResultSet,此Result使用PreparedStatement類對象執行SQL。因爲自動設置了useServerPrepStmts屬性,據上面[Server-Side Prepared Statement](#Server-Side Prepared Statement)小節的分析,會創建一個數據庫Server端Prepare的語句:

if (useServerFetch()) {
    this.results = createResultSetUsingServerFetch(sql);

    return this.results;
}
private ResultSetInternalMethods createResultSetUsingServerFetch(String sql) throws SQLException {
    synchronized (checkClosed().getConnectionMutex()) {
        java.sql.PreparedStatement pStmt = this.connection.prepareStatement(sql, this.resultSetType, this.resultSetConcurrency);

        pStmt.setFetchSize(this.fetchSize);

        if (this.maxRows > -1) {
            pStmt.setMaxRows(this.maxRows);
        }

        statementBegins();

        pStmt.execute();

        //
        // Need to be able to get resultset irrespective if we issued DML or not to make this work.
        //
        ResultSetInternalMethods rs = ((StatementImpl) pStmt).getResultSetInternal();

        rs.setStatementUsedForFetchingRows((com.mysql.jdbc.PreparedStatement) pStmt);

        this.results = rs;

        return rs;
    }
}

最後PreparedStatement.execute()會被導向ServerPreparedStatement.executeInternal(),然後到其serverExecute()方法裏的這裏,構建並向數據庫Server發送了一個COM_STMT_EXECUTE包,包中標誌字節中寫入了OPEN_CURSOR_FLAG標誌,這個標誌的值與MySQL Server代碼裏的CURSOR_TYPE_READ_ONLY是一個值:

packet.clear();
packet.writeByte((byte) MysqlDefs.COM_EXECUTE);
packet.writeLong(this.serverStatementId);

if (this.connection.versionMeetsMinimum(4, 1, 2)) {
    if (isCursorRequired()) {
        packet.writeByte(MysqlDefs.OPEN_CURSOR_FLAG);
    } else {
        packet.writeByte((byte) 0); // placeholder for flags
    }

    packet.writeLong(1); // placeholder for parameter iterations
}

MySQL Server的處理

MySQL Server中是怎麼處理這個標誌的? 在Protocol_classic::parse_packet中對COM_STMT_EXECUTE包的處理中是讀取了flags域段的:

{
    if (packet_length < 9)
        goto malformed;
    data->com_stmt_execute.stmt_id= uint4korr(raw_packet);
    data->com_stmt_execute.flags= (ulong) raw_packet[4];
    /* stmt_id + 5 bytes of flags */
    /*
      FIXME: params have to be parsed into an array/structure
      by protocol too
    */
    data->com_stmt_execute.params= raw_packet + 9;
    data->com_stmt_execute.params_length= packet_length - 9;
    break;
}

又在這裏,從flags中解析出了是否打開遊標的標誌位:

open_cursor= MY_TEST(flags & (ulong) CURSOR_TYPE_READ_ONLY);

再一路到這個位置,判斷此標誌並調用打開遊標的函數:

if (open_cursor)
{
    lex->safe_to_cache_query= 0;
    error= mysql_open_cursor(thd, &result, &cursor);
}

在這個函數裏實現了MySQL的Server端物化遊標(materialized cursor) ,即用臨時表來存放查詢結果集,這個在MySQL官方文檔中有所描述:

In MySQL, a server-side cursor is materialized into an internal temporary table. Initially, this is a MEMORY table, but is converted to a MyISAM table when its size exceeds the minimum value of the max_heap_table_size and tmp_table_size system variables.

ResultSet獲取數據

JDBC 客戶端對於Sever-Side Cursor Statement返回的ResultSet使用了RowDataCursor來獲取更多的記錄:

 protected ResultSetImpl getResultSet(...) {
     ...
    if (usingCursor) {
        RowData rows = new RowDataCursor(this, prepStmt, fields);

        ResultSetImpl rs = buildResultSetWithRows(callingStatement, catalog, fields, rows, resultSetType, resultSetConcurrency, isBinaryEncoded);

        if (usingCursor) {
            rs.setFetchSize(callingStatement.getFetchSize());
        }

        return rs;
    }
    ...
 }

而RowDataCursor獲取更多記錄的方法fetchMoreRows()又會去使用MysqlO的方法fetchRowsViaCursor(),最終是發送COM_STMT_FETCH包給Server,包裏有SQL語句在Server端的id 與 欲讀取記錄個數:

this.sharedSendPacket.clear();

this.sharedSendPacket.writeByte((byte) MysqlDefs.COM_FETCH);
this.sharedSendPacket.writeLong(statementId);
this.sharedSendPacket.writeLong(fetchSize);

sendCommand(MysqlDefs.COM_FETCH, null, this.sharedSendPacket, true, null, 0);

OceanBase的實現

Server-Side Prepared Statement

開源的OceanBase 0.4源碼中,有對Preapare SQL語句的功能實現代碼,它讀取了MySQL COM_STMT_PREPARE包,並有函數方法對此進行了處理:

int ObMySQLServer::do_com_prepare(ObMySQLCommandPacket* packet)
{
    ...
    ret = ObSql::stmt_prepare(packet->get_command(), result, context);
    FILL_TRACE_LOG("stmt_prepare query_result=(%s)", to_cstring(result));
    ...
}
int ObSql::stmt_prepare(const common::ObString &stmt, ObResultSet &result, ObSqlContext &context)
{
    ...
    result.set_stmt_type(ObBasicStmt::T_PREPARE);
    if (need_rebuild_plan(context.schema_manager_, item))
    {
        //get wlock of item free old plan construct new plan if possible
        ret = try_rebuild_plan(stmt, result, context, item, do_prepare, false);
        if (OB_SUCCESS != ret)
        {
            TBSYS_LOG(WARN, "can not rebuild prepare ret=%d", ret);
        }
    }
    else
    {
        TBSYS_LOG(DEBUG, "Latest table schema is same with phyplan in ObPsStore");
    }
    ...
}

上面的代碼,說明在0.4版本中是實現了此功能的,而且在隨源碼提供的《OceanBase 0.5 SQL 參考指南.pdf》裏面,也是有對Prepare語句語法說明的。 但在螞蟻金融雲OceanBase官方文檔《用戶指南(SQL語法參考)》中,又說不支持此功能:

  • 不支持prepare, OceanBase不需要你使用prepare。

可能是0.5之後版本,改動了實現架構/功能什麼的,取消或暫不能支持此功能。

Server-Side Cursor

從上面對MySQL JDBC的實現分析,OCeanBase要兼容MySQL,必須實現對以下請求包的處理:

對COM_STMT_PREPARE的處理,即對Server-Side Prepared Statement功能的支持,這個在上面已經提到,0.5版本時可能還是支持的,金融雲上的新版本是不支持的。

對COM_STMT_EXECUTE的處理代碼,節略如下:

int ObMySQLServer::do_com_execute(ObMySQLCommandPacket* packet)
{
    ...
    ret = parse_execute_params(&context, packet, *param, stmt_id, *param_type);
    ..
}

parse_execute_params方法中跳過、忽略了請求包中的一些什麼東西:

ObMySQLUtil::get_uint4(payload, stmt_id);
payload += 5; // skip flags && iteration-count

從MySQL COM_STMT_EXECUTE包結構描述,可知忽略的內容中包括標誌位字節。也就是說,沒有理會客戶端設置的useCursorFetch屬性反映到請求包中的標誌CURSOR_TYPE_READ_ONLY:

COM_STMT_EXECUTE
  execute a prepared statement

  direction: client -> server
  response: COM_STMT_EXECUTE Response

  payload:
    1              [17] COM_STMT_EXECUTE
    4              stmt-id
    1              flags
    4              iteration-count
      if num-params > 0:
    n              NULL-bitmap, length: (num-params+7)/8
    1              new-params-bound-flag
      if new-params-bound-flag == 1:
    n              type of each parameter, length: num-params * 2
    n              value of each parameter

  example:
    12 00 00 00 17 01 00 00    00 00 01 00 00 00 00 01    ................
    0f 00 03 66 6f 6f                                     ...foo

OceanBase代碼中沒有對COM_STMT_FETCH的處理代碼。

因此,可以說明OceanBase並不支持Server-Side Cursor。這在OceanBase官方文檔中有體現:

不支持可更新視圖、存儲過程、觸發器、遊標。

另外,還有:

不支持臨時表。

結論

如果使用OceanBase做爲數據庫,應用爲規避其對Prepare、Cursor的不支持,需要:

對於Java應用,JDBC設置屬性useServerPrepStmts=false(MySQL Connector/J的默認值),採用Client-Side Prepared Statement特性,即將綁定參數值拼入SQL語句,形成完整SQL發給數據庫Server端,不調用數據庫端Prepare,這些處理由MySQL JDBC Driver完成,應用無感知。

對於C應用,做不到應用無感知,不能使用MySQL Api函數庫中所有與Prepare功能相關的函數,包括但不限於:mysql_stmt_prepare/mysql_stmt_bind_prepare/mysql_stmt_bind_result/….,需要應用自己完成MySQL JDBC Driver所實現的Client-Side Prepared Statement功能

不管Java還是C應用,都要一次性獲取全部結果集到客戶端,然後再處理。即採用默認的useCursorFetch屬性值(false)。

爲使用強大的分佈式數據庫,必須有所犧牲,沒有十全十美,只有折中。


參考資料鏈接:

  1. https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-implementation-notes.html
  2. MySQL JDBC源碼 - https://github.com/mysql/mysql-connector-j
  3. MySQL Server源碼 - https://github.com/mysql/mysql-server
  4. MySQL 5.7文檔章節 - C.3 Restrictions on Server-Side Cursors
  5. 螞蟻金融雲OceanBase文檔《用戶指南》- https://www.cloud.alipay.com/docs/2/26469) - “OceanBase SQL快速概覽”
  6. OceanBase 0.4源碼 - https://github.com/alibaba/oceanbase
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章