본문 바로가기

MySQL/R&D

MySQL JSON 이 BLOB, TEXT 에 비해 느린 이유 (JSON source 분석)

배경

해당 내용은 JSON Function 을 전혀 사용하지않고, json 의 모든 값들을 Fetch 할 때의 이야기이다.

=========================================================================

 

MySQL 에서 JSON Data 를 Client 단으로 보내줄 때 다른 Type 의 컬럼들에 비해 CPU Usage 를 많이 사용한다.

 

이는 MySQL 의 경우 JSON type 을 내부적으로 binary 형태로 저장하고, TEXT type 은 string 형태로 저장하기때문이다.

Client 단에서 데이터를 요청했을 때, TEXT type 의 경우 type conversion 없이 string 형태 그대로 전송을 해주면되지만,

JSON type 의 경우는 binary data 의 type conversion 후에 전송을 해야한다.

이 때 CPU 연산이 필요한 것이고, 이로 인해 CPU Usage 가 올라가게 된다.

 

따라서 동일한 qps 를 요청 받았을 경우, JSON type 의 데이터를 요청하는 쿼리라면,

TEXT type 의 데이터를 요청하는 쿼리보다 CPU 연산을 더 할 수밖에 없다.

 

그러나 JSON 과 동일하게 내부적으로 binary data 로 저장하는 BLOB Type 같은 경우에는

JSON 과 비슷한 속도한 속도가 아니라, 더 빠른 속도를 보였다.

 

이 이유가 무엇인지 소스 단에서 확인해보고자 한다.

 

일부 불필요한 과정이 생략되어 있을 수 있다.

(해당 문서는 MySQL 8.0.18 기반이다.)

 

 

소스 코드 분석

1. JSON 의 Type Conversion 이 어디에서 이루어지는지?

1) ExecuteIteratorQuery ()

# sql_union.cc

...

...

    for (;;) {



// m_root_iterator->Read() 를 통해서 Handler API 를 통해 실제 Record 읽어온다.

      int error = m_root_iterator->Read();

      DBUG_EXECUTE_IF("bug13822652_1", thd->killed = THD::KILL_QUERY;);



      if (error > 0 || thd->is_error())  // Fatal error

        return true;

      else if (error < 0)

        break;

      else if (thd->killed)  // Aborted by user

      {

        thd->send_kill_message();

        return true;

      }



      ++*send_records_ptr;



//  send_data() 를 통해서 읽어온 Record 의 Result Set 을 생성한다.

      if (query_result->send_data(thd, *fields)) {

        return true;

      }

      thd->get_stmt_da()->inc_current_row_for_condition();

    }

...

...


2) m_root_iterator->Read() 를 통해서 Handler API 를 통해 실제 Record 읽어온다.

# record.cc

...

...

int TableScanIterator::Read() {

  int tmp;

  while ((tmp = table()->file->ha_rnd_next(m_record))) {

    /*

      ha_rnd_next can return RECORD_DELETED for MyISAM when one thread is
      
      reading and another deleting without locks.

    */

    if (tmp == HA_ERR_RECORD_DELETED && !thd()->killed) continue;

    return HandleError(tmp);

  }

  if (m_examined_rows != nullptr) {

    ++*m_examined_rows;

  }

  return 0;

}

...

...



// TableScanIterator 는 Handler API 를 통해 storage Engine 을 호출한다.

// innodb 의 경우 ha_innodbase 를 통해서 진행하게 됨



// MySQL Engine 에서 ha_rnd_next CALL 이 되면

// 내부에서 handler::ha_rnd_next() 를 CALL

// 더 내부에선 ha_innobase::rnd_next() 를 CALL

// 이 시점부터 실제 record fetch 가 시작



# ha_innodb.cc

...

...

int ha_innobase::general_fetch(

    uchar *buf,      /*!< in/out: buffer for next row in MySQL

                     format */

    uint direction,  /*!< in: ROW_SEL_NEXT or ROW_SEL_PREV */

    uint match_mode) /*!< in: 0, ROW_SEL_EXACT, or

                     ROW_SEL_EXACT_PREFIX */

{

...

...

  if (!intrinsic) {

    ret = row_search_mvcc(buf, PAGE_CUR_UNSUPP, m_prebuilt, match_mode,

                          direction);



  } else {

    ret = row_search_no_mvcc(buf, PAGE_CUR_UNSUPP, m_prebuilt, match_mode,

                             direction);

  }



  innobase_srv_conc_exit_innodb(m_prebuilt);



  int error;



// Record 를 성공적으로 읽어 왔을 경우 thread pointer 가 읽어온 row 를 add

  switch (ret) {

    case DB_SUCCESS:

      error = 0;

      srv_stats.n_rows_read.add(thd_get_thread_id(trx->mysql_thd), 1);

      break;

...

...

 


3) send_data() 를 통해서 읽어온 Record 의 Result Set 을 생성한다. (MySQL 엔진이 Handler API 를 통해 Storage Engine 에서 데이터를 읽어오면, 아래 function 을 통해 result set 을 string buffer 에 채운다.)

# sql_union.cc



bool Query_result_send::send_data(THD *thd, List<Item> &items) {

  Protocol *protocol = thd->get_protocol();

  DBUG_TRACE;



  protocol->start_row();



// thd->send_result_set_row() 를 통해 result set 을 생성한다.

  if (thd->send_result_set_row(&items)) {

    protocol->abort_row();

    return true;

  }



  thd->inc_sent_row_count(1);

  return protocol->end_row();

}









# sql/sql_class.cc

// item->send() function 을 통해 &str_buffer 에 실제 field 들의 데이터들이 채워지게 된다.

...

...

bool THD::send_result_set_row(List<Item> *row_items) {

  char buffer[MAX_FIELD_WIDTH];

  String str_buffer(buffer, sizeof(buffer), &my_charset_bin);

  List_iterator_fast<Item> it(*row_items);



  DBUG_TRACE;



  for (Item *item = it++; item; item = it++) {

    if (item->send(m_protocol, &str_buffer) || is_error()) return true;

    /*

      Reset str_buffer to its original state, as it may have been altered in

      Item::send().

    */

    str_buffer.set(buffer, sizeof(buffer), &my_charset_bin);

  }

  return false;

}

...

...





// item->send() 내부에는 아래와 같은 phase 로 protocol 을 맞춰 읽은 데이터를 보낸다.

# item.cc

bool Item_field::send(Protocol *protocol, String *) {

  return protocol->store_field(result_field);

}



# protocol_classic.cc

bool Protocol_classic::store_field(const Field *field) {

  return field->send_to_protocol(this);

}



#field.cc

bool Field::send_to_protocol(Protocol *protocol) const {

  if (is_null()) return protocol->store_null();

  char buff[MAX_FIELD_WIDTH];

  String tmp(buff, sizeof(buff), charset());

  String *res = val_str(&tmp);

  return res ? protocol->store(res) : protocol->store_null();

}

 


크게 위와 같은 과정들을 통해 실행한 Thread 가 가지고 있는 protocol struct 에 fetch 한 실제 데이터를 보낸다.

protocol variable 은 정해진 mysql packet 규격에 맞춰 fetch 된 데이터를 Client 에 전송하는 strunct 이다.

 

데이터들을 읽은 후에 Client 로 보내질 패킷들을 살펴보면, JSON 이나 BLOB 이나 모두 Text 형태를 취하는 것을 알 수 있다.

 

 

이 때 m_ptr 포인터 변수가 가리키고 있는 메모리의 값을 살펴보면, Client 로 보내질 패킷을 볼 수 있다.

실제 JSON/BLOB 필드의 패킷을 살펴보면 아래와 같다.

# JSON

72 6f 73 73 20 ec 97 ad 22 7d 7d 2c 20 7b 22 63  ross ..."}}, {"c

6f 64 65 22 3a 20 22 36 30 32 32 32 38 36 22 2c  ode": "6022286",

20 22 74 79 70 65 22 3a 20 22 54 52 41 49 4e 5f   "type": "TRAIN_

53 54 41 54 49 4f 4e 22 2c 20 22 76 61 6c 75 65  STATION", "value

22 3a 20 7b 22 65 6e 22 3a 20 22 4c 6f 6e 64 6f  ": {"en": "Londo

6e 20 4d 61 72 79 6c 65 62 6f 6e 65 20 53 74 61  n Marylebone Sta

74 69 6f 6e 22 2c 20 22 6b 6f 22 3a 20 22 4c 6f  tion", "ko": "Lo

 

# BLOB

72 6f 73 73 20 ec 97 ad 22 7d 7d 2c 20 7b 22 63  ross ..."}}, {"c

6f 64 65 22 3a 20 22 36 30 32 32 32 38 36 22 2c  ode": "6022286",

20 22 74 79 70 65 22 3a 20 22 54 52 41 49 4e 5f   "type": "TRAIN_

53 54 41 54 49 4f 4e 22 2c 20 22 76 61 6c 75 65  STATION", "value

22 3a 20 7b 22 65 6e 22 3a 20 22 4c 6f 6e 64 6f  ": {"en": "Londo

6e 20 4d 61 72 79 6c 65 62 6f 6e 65 20 53 74 61  n Marylebone Sta

74 69 6f 6e 22 2c 20 22 6b 6f 22 3a 20 22 4c 6f  tion", "ko": "Lo

 

위의 결과 값처럼 Packet 은 JSON 과 BLOB 동일한 value 를 가진다.

따라서 JSON 과 BLOB 은 실제 레코드를 fetch 후,  Client 로 보내기 전 Protocol Struct 에서는 동일한 value 를 가진다는 의미가 된다.

 

그 의미는 MySQL 에서 JSON 의 Type Conversion 은 JDBC 같은 드라이버 단이 아닌, MySQL 내에서 이루어진다고 판단할 수 있다.

그래서 MySQL 내부에서 Type Conversion 을 하는 처리가 있을 것이라고 생각하고 추측하고 추가로 확인을 한다.

 

 

2. JSON / BLOB / TEXT 의 Type Conversion 과정

1번에서 보여준 data sending 과정 중, item→send() function 을 호출하면서 아래와 같이 protocol 로 보내는 과정이 있다.

bool Field::send_to_protocol(Protocol *protocol) const {

  if (is_null()) return protocol->store_null();

  char buff[MAX_FIELD_WIDTH];

  String tmp(buff, sizeof(buff), charset());



/***************************/

/***************************/

/*** 중요한 부분은 이 라인이다 ***/

  String *res = val_str(&tmp);

/***************************/

/***************************/



  return res ? protocol->store(res) : protocol->store_null();

}





※ 해당 부분이 실행되는 시점에 stack trace 는 아래와 같다.

mysqld`Field::send_to_protocol       at field.cc:1762:17

mysqld`Protocol_classic::store_field         at protocol_classic.cc:1289:17

mysqld`Item_field::send          at item.cc:7028:20

mysqld`THD::send_result_set_row          at sql_class.cc:2516:15

mysqld`Query_result_send::send_data          at query_result.cc:93:12

mysqld`SELECT_LEX_UNIT::ExecuteIteratorQuery         at sql_union.cc:1519:25

mysqld`SELECT_LEX_UNIT::execute          at sql_union.cc:1577:12

mysqld`Sql_cmd_dml::execute_inner        at sql_select.cc:899:15

mysqld`Sql_cmd_dml::execute          at sql_select.cc:704:9

mysqld`mysql_execute_command         at sql_parse.cc:4453:29

mysqld`mysql_parse       at sql_parse.cc:5257:19

mysqld`dispatch_command          at sql_parse.cc:1765:7

mysqld`do_command        at sql_parse.cc:1273:18

mysqld`handle_connection         at connection_handler_per_thread.cc:302:13

mysqld`pfs_spawn_thread          at pfs.cc:2854:3

libsystem_pthread.dylib`_pthread_body + 126

libsystem_pthread.dylib`_pthread_start + 66

libsystem_pthread.dylib`thread_start + 13

 


아래 function 들을 살펴보면 폴리모피즘으로 구현된 var_str() 을 확인할 수 있다.

# field.cc

## JSON



String *Field_json::val_str(String *buf1,

                            String *buf2 MY_ATTRIBUTE((unused))) const {

  ASSERT_COLUMN_MARKED_FOR_READ;



  /*

    Per contract of Field::val_str(String*,String*), buf1 should be

    used if the value needs to be converted to string, and buf2 should

    be used if the string value is already known. We need to convert,

    so use buf1.

  */

  buf1->length(0);



  Json_wrapper wr;

  if (val_json(&wr) || wr.to_string(buf1, true, field_name)) buf1->length(0);



  return buf1;

}



## BLOB

String *Field_blob::val_str(String *val_buffer MY_ATTRIBUTE((unused)),

                            String *val_ptr) const {

  ASSERT_COLUMN_MARKED_FOR_READ;

  char *blob;

  memcpy(&blob, ptr + packlength, sizeof(char *));

  if (!blob)

    val_ptr->set("", 0, charset());  // A bit safer than ->length(0)

  else

    val_ptr->set((const char *)blob, get_length(ptr), charset());

  return val_ptr;

}



## STRING (varstr 은 따로 있다)

String *Field_string::val_str(String *val_buffer MY_ATTRIBUTE((unused)),

                              String *val_ptr) const {

  ASSERT_COLUMN_MARKED_FOR_READ;

  /* See the comment for Field_long::store(long long) */

  DBUG_ASSERT(table->in_use == current_thd);

  size_t length;

  if (table->in_use->variables.sql_mode & MODE_PAD_CHAR_TO_FULL_LENGTH)

    length = my_charpos(field_charset, ptr, ptr + field_length,

                        field_length / field_charset->mbmaxlen);

  else

    length = field_charset->cset->lengthsp(field_charset, (const char *)ptr,

                                           field_length);

  val_ptr->set((const char *)ptr, length, field_charset);

  return val_ptr;

}

 


소스코드에서도 보이듯이 BLOB 과 STRING 으로 호출할 경우, 특별한 과정없이 val_ptr variable 에 가지고 있는 값들을 set 만하고 return 을 하는 형태이다.

 

하지만 JSON 의 경우는 Json_wrapper 를 이용하여 변환하는 과정이 있다.

아래 구문을 통해 binary data → json_wrapper → string 의 convert 과정을 거친다.

if (val_json(&wr) || wr.to_string(buf1, true, field_name)) buf1->length(0);

 

1) val_json()

# field.cc



bool Field_json::val_json(Json_wrapper *wr) const {

  DBUG_TRACE;

  ASSERT_COLUMN_MARKED_FOR_READ;



  String tmp;

// binary data(blob이라고 정의된) 주소를 지정한다.

  String *s = Field_blob::val_str(&tmp, &tmp);



// *s 포인터가 가리키는 binary data 를 json 형태로 변환한다.

  json_binary::Value v(json_binary::parse_binary(s->ptr(), s->length()));

  if (v.type() == json_binary::Value::ERROR) {

    /* purecov: begin inspected */

    my_error(ER_INVALID_JSON_BINARY_DATA, MYF(0));

    return true;

    /* purecov: end */

  }

  *wr = Json_wrapper(v);



  return false;

}


2) wr.to_string()

# json_dom.cc



bool Json_wrapper::to_string(String *buffer, bool json_quoted,

                             const char *func_name) const {

// "1) val_json()" 과정을 통해 json_wrapper 변환된 데이터를 string 형태로 변환시킨다.

  buffer->set_charset(&my_charset_utf8mb4_bin);
  
// wrapper_to_string 에서는 json wrapper 데이터를 실제 JSON 형태대로 만든다. "{", ":", "}" 를 추가하는 등
  return wrapper_to_string(*this, buffer, json_quoted, false, func_name, 0);
}
 
 
# json_dom.cc
// wapper_to_string() 의 일부분만 발췌
    case enum_json_type::J_OBJECT: {
      if (check_json_depth(++depth)) return true;
 
      if (buffer->append('{')) return true; /* purecov: inspected */
 
      bool first = true;
      for (const auto &iter : Json_object_wrapper(wr)) {
        if (!first && append_comma(buffer, pretty))
          return true; /* purecov: inspected */
 
        first = false;
 
        if (pretty && newline_and_indent(buffer, depth))
          return true; /* purecov: inspected */
 
        const MYSQL_LEX_CSTRING &key = iter.first;
        if (print_string(buffer, true, key.str, key.length) ||
            buffer->append(':') || buffer->append(' ') ||
            wrapper_to_string(iter.second, buffer, true, pretty, func_name,
                              depth))
          return true; /* purecov: inspected */
      }



※ 당연한 이야기이지만, 반대로 JSON type data 를 저장 시에도 동일하게 binary data 형태로 convert 를 수행 후 저장을 한다.

# field.cc



type_conversion_status Field_json::store_json(const Json_wrapper *json) {

  ASSERT_COLUMN_MARKED_FOR_WRITE;



  StringBuffer<STRING_BUFFER_USUAL_SIZE> tmpstr;

  String *buffer = json->is_binary_backed_by(&value) ? &tmpstr : &value;



  if (json->to_binary(table->in_use, buffer)) return TYPE_ERR_BAD_VALUE;



  return store_binary(buffer->ptr(), buffer->length());

}

 

 

결론

json, blob, text 세 형태 모두 field value 에는 실제 데이터가 있는 pointer 값만 있고 실제 데이터는 외부에 있는 형태이다.

따라서 type conversion 을 할 필요가 없는 select count(*)  쿼리는 JSON / BLOB / TEXT 의 부하는 동일하다. 

 

하지만 위의 소스코드에서 본 것 같이 JSON type 의 data 를 Client 단으로 보내줘야한다면,

JSON 은 BLOB, TEXT 타입들에 비해 Type Conversion 횟수가 더 많기때문에 CPU 연산을 더 할 수 밖에 없고,

이는 필연적으로 전체적인 응답속도 하락에 영향을 미칠 수 밖에 없다.