namalzi

← 글 목록

MariaDB 소스 파헤치기 5: 프로토콜과 명령 처리 — 패킷 하나가 dispatch_command에 닿기까지

MariaDB · 게시

클라이언트가 보낸 바이트가 어떻게 패킷이 되고, 첫 바이트 하나로 COM_QUERY·COM_PING·COM_STMT_EXECUTE가 갈리고, 결과가 다시 패킷이 되어 나가는지를 sql/net_serv.cc, sql_parse.cc의 do_command()·dispatch_command(), protocol.cc로 따라갑니다. bash의 /dev/tcp로 클라이언트 라이브러리 없이 서버와 직접 말을 주고받고, 쿼리 캐시가 왜 파싱보다 먼저 문자열로 찾는지도 실행해서 확인합니다.

이 글에서 다루는 것

"MariaDB 소스 파헤치기" 연재의 다섯 번째 글입니다. 4편에서는 연결이 들어오면 스레드 하나(또는 스레드 풀의 워커)가 그 연결을 맡고, 로그인을 마친 뒤 do_command()를 되풀이해 부르는 데까지 봤습니다. 이번 글은 그 do_command() 한 번의 안쪽입니다. 2편 지도로 말하면 ② "THD · 명령" 구역과, 맨 끝의 ⑨ "결과 보내기" 구역을 확대합니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 MariaDB Server
버전 13.2.0 (main, alpha)
커밋 c894d97781 (2026-10-19)
주로 읽는 파일 sql/net_serv.cc, vio/viosocket.c, sql/sql_parse.cc(do_command·dispatch_command·mysql_parse), sql/protocol.cc, sql/sql_cache.cc, sql/sql_prepare.cc

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

dispatch_command() 하나만 900줄이 넘고, protocol.cc는 약 2천 줄(1,998줄), sql_cache.cc는 5천 줄이 넘습니다(5,342줄). 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 압축, SSL, 복제용 명령처럼 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

2편 지도에서 이 글이 다루는 곳. 연결(4편) 다음의 ② THD·명령 — do_command가 my_net_read_packet으로 패킷을 읽고 dispatch_command로 넘기는 칸 — 과, 실행(9편) 뒤의 ⑨ 결과 보내기 — select_send에서 Protocol, NET, Vio로 내려가는 칸 — 이 파랗게 칠해져 있다. 파싱(6편), 준비(7편), 최적화(8편), 실행(9편), handler 경계(9편), 엔진(10~12편), 기동·플러그인(3편)은 회색이다. 파란 칸이 이 글에서 읽는 곳입니다. 요청이 들어오는 입구와 결과가 나가는 출구가 둘 다 이 글의 몫입니다.

먼저 알아 둘 말

THD 는 연결 하나의 모든 상태를 담은 객체입니다(2편, 만들어지는 곳은 4편). 이 글에서는 그 안의 네 가지 — 소켓 쪽의 net, 결과를 만드는 protocol, 문장의 최종 답을 적어 두는 Diagnostics_area, 문장마다 비우는 메모리 mem_root — 만 따라다닙니다.

패킷(packet). MariaDB 클라이언트와 서버는 TCP(또는 유닉스 소켓) 위에서 "패킷"이라는 단위로 말합니다. HTTP처럼 줄 바꿈으로 끊는 글자 프로토콜이 아니라, 앞에 길이를 적고 그만큼 읽는 바이너리 프로토콜입니다. 패킷마다 4바이트 머리 — 길이 3바이트와 순번(sequence number) 1바이트 — 가 붙습니다. 소스에서는 순번을 pkt_nr라고 부릅니다.

명령(command), COM_*. 로그인을 마친 클라이언트가 보내는 패킷은 본문 첫 바이트가 "무엇을 해 달라"는 명령 번호입니다. include/mysql_com.h의 enum enum_server_command에 COM_SLEEP(0)부터 COM_QUIT(1), COM_QUERY(3), COM_PING(14), COM_STMT_PREPARE(22), COM_STMT_EXECUTE(23) 같은 값이 있습니다. 우리가 쓰는 SQL 문장은 거의 다 COM_QUERY 하나에 실려 갑니다. SHOW PROCESSLIST의 Command 열(Sleep, Query …)이 바로 이 번호의 이름입니다.

NET과 Vio. NET(include/mysql_com.h의 struct st_net)은 패킷을 읽고 쓰는 층입니다. 버퍼, 현재 순번, 최대 패킷 크기를 들고 있습니다. 그 아래의 Vio(include/violite.h의 struct st_vio, "virtual I/O")는 TCP 소켓, 유닉스 소켓, SSL, 윈도의 이름 있는 파이프를 같은 함수 포인터로 감싼 소켓 추상화입니다. NET은 "패킷", Vio는 "바이트"를 압니다.

Protocol. 결과를 패킷 본문으로 인코딩하는 클래스입니다(sql/protocol.h). 같은 정수 1도 보통 쿼리(COM_QUERY)에서는 글자 "1"로, 준비된 문장(COM_STMT_EXECUTE)에서는 4바이트 정수로 보냅니다. 앞쪽이 Protocol_text, 뒤쪽이 Protocol_binary입니다.

Diagnostics_area. 문장 하나가 끝날 때 클라이언트에게 돌려줄 최종 답 — OK인지, 결과 집합의 끝(EOF)인지, 오류인지 — 와 영향받은 행 수, 오류 번호를 적어 두는 칸입니다(sql/sql_error.h). 실행 중에 my_ok()나 my_error()가 여기에 적기만 하고, 실제로 보내는 일은 맨 끝에서 한 번 합니다.

1. 패킷 하나의 모양 — net_serv.cc

sql/net_serv.cc는 클라이언트 라이브러리와 서버가 함께 쓰는 파일입니다. 첫 주석이 이 파일이 하는 일을 다 말합니다 — "Writes are cached into net_buffer_length big packets. Read packets are reallocated dynamicly when reading big packets. Each logical packet has the following pre-info: 3 byte length & 1 byte package-number."(22–27행)

패킷의 모양. 맨 위는 패킷 하나: 길이 3바이트(리틀 엔디언, 최대 0xffffff), 순번 1바이트(명령마다 0부터, 패킷마다 1씩), 그리고 본문(요청이면 첫 바이트가 COM_ 명령). 가운데는 예로 COM_QUERY 'SELECT 1'을 보낸 바이트: 09 00 00(길이 9), 00(순번 0), 03(COM_QUERY), SELECT 1 여덟 바이트. 아래는 20,000,009바이트짜리 본문이 ff ff ff 04 머리와 16,777,215바이트, 그리고 0a 2d 31 05 머리와 나머지 3,222,794바이트로 쪼개지는 모습. 패킷의 4바이트 머리. 아래 두 예는 이 글의 예제에서 실제로 오간 바이트입니다.

쓰기: my_net_write()

패킷을 쓰는 함수는 my_net_write()입니다. 길이가 MAX_PACKET_LENGTH(= 256×256×256−1 = 0xffffff, 16MB에서 1바이트 모자란 값, include/mysql_com.h 64행)를 넘으면 쪼갭니다.

  while (len >= MAX_PACKET_LENGTH)
  {
    const ulong z_size = MAX_PACKET_LENGTH;
    int3store(buff, z_size);
    buff[3]= (uchar) net->pkt_nr++;
    if (net_write_buff(net, buff, NET_HEADER_SIZE) ||
	net_write_buff(net, packet, z_size))
    {
      MYSQL_NET_WRITE_DONE(1);
      return 1;
    }
    packet += z_size;
    len-=     z_size;
  }
  /* Write last packet */
  int3store(buff,len);
  buff[3]= (uchar) net->pkt_nr++;
  if (net_write_buff(net, buff, NET_HEADER_SIZE))
  {
    MYSQL_NET_WRITE_DONE(1);
    return 1;
  }

sql/net_serv.cc 425–446행

세 가지를 읽을 수 있습니다.

  1. 길이는 3바이트입니다. 그래서 한 패킷 본문은 0xffffff바이트를 넘을 수 없고, 넘으면 0xffffff짜리 조각을 이어서 보냅니다. 받는 쪽은 "길이가 정확히 0xffffff면 다음 조각이 더 있다"고 알아듣습니다.
  2. 순번은 패킷마다 1씩 늘어납니다(net->pkt_nr++). 조각도 패킷 하나로 세므로 순번을 하나씩 씁니다.
  3. 바로 소켓에 쓰지 않습니다. net_write_buff()는 net->buff라는 버퍼에 복사만 하고, 버퍼(net_buffer_length, 기본 16384바이트 — sys_vars.cc의 Sys_net_buffer_length)가 차거나 net_flush()가 불릴 때 net_real_write()가 한꺼번에 vio_write()합니다. 결과 행 하나마다 시스템 호출을 하지 않으려는 것입니다(5절의 gdb 예제에서 확인합니다).

마지막 조각이 정확히 0xffffff바이트로 끝나면 어떻게 될까요? 루프 조건이 len >= MAX_PACKET_LENGTH라서 그 경우에도 한 번 더 돌고, 마지막에 길이 0짜리 패킷이 하나 더 나갑니다. 위 코드 바로 앞의 주석이 "The last packet may even have a length of 0"이라고 적어 둔 그 경우입니다.

읽기: my_real_read()와 max_allowed_packet

읽는 쪽은 my_net_read_packet() → my_net_read_packet_reallen() → my_real_read()입니다. my_real_read()는 먼저 머리 4바이트를 읽어 순번을 확인하고(어긋나면 ER_NET_PACKETS_OUT_OF_ORDER), 길이를 꺼낸 뒤 버퍼가 모자라면 키웁니다.

      len= uint3korr(net->buff + net->where_b);
      if (!len) /* End of big multi-packet */
        goto end;
      helping= MY_MAX(len, *complen) + net->where_b;
      /* The necessary size of net->buff */
      if (helping >= net->max_packet)
      {
        if (net_realloc(net, helping))
        {
          len= packet_error; /* Return error and close connection */
          goto end;
        }
      }

sql/net_serv.cc 952–964행

버퍼를 키우는 net_realloc()이 max_allowed_packet을 확인하는 곳입니다.

  if (length >= net->max_packet_size)
  {
    DBUG_PRINT("error", ("Packet too large. Max size: %lu",
                         net->max_packet_size));
    /* @todo: 1 and 2 codes are identical. */
    net->error= 1;
    net->last_errno= ER_NET_PACKET_TOO_LARGE;
    MYSQL_SERVER_my_error(ER_NET_PACKET_TOO_LARGE, MYF(0));
    DBUG_RETURN(1);
  }

sql/net_serv.cc 211–220행

net->max_packet_size는 연결을 만들 때 my_net_local_init()이 max_allowed_packet(기본 16MB, Sys_max_allowed_packet)으로 채웁니다 — 정확히는 net_buffer_length와 둘 중 큰 값입니다(sql/sql_client.cc 40–41행). 그러니 max_allowed_packet은 "받는 버퍼를 이보다 크게 키우지 않겠다"는 한도입니다. 처음 버퍼는 net_buffer_length(16KB)만큼만 잡고, 큰 패킷이 올 때만 키웁니다.

길이가 0xffffff인 조각이 오면 my_net_read_packet_reallen()이 이어 붙입니다(1084–1099행). where_b를 조각 길이만큼 밀면서 같은 버퍼 뒤쪽에 다음 조각을 읽어 붙이고, 0xffffff보다 짧은 조각이 오면 멈춥니다. 위에서 쓰는 쪽이 보낸 "길이 0짜리 마지막 패킷"도 여기서 루프를 끝내는 역할을 합니다.

예제 1: 패킷 머리를 눈으로 보기

이 예제와 이 글의 다른 예제는 모두 기준 커밋(c894d97781)을 그대로 Debug 빌드한 MariaDB 13.2.0(13.2.0-MariaDB-debug)으로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. 예제 폴더는 ex/u05-packets입니다.

my_net_write()와 my_real_read()에는 DBUG_DUMP("packet_header", …)가 들어 있습니다. Debug 빌드에서는 세션 변수 debug_dbug로 이 키워드만 켜서 파일로 받을 수 있습니다. 서버는 --max-allowed-packet=64M으로 띄웠습니다.

# 이 글을 위해 쓴 예제입니다 (MariaDB 의 소스가 아닙니다)
# ...
mariadb -e "SET SESSION debug_dbug = 'd,packet_header:o,/tmp/pk1.trace'; SELECT 1 AS one;" > /dev/null
grep -A1 "packet_header" /tmp/pk1.trace | grep -v "^--" | head -20
# ...
Protocol::net_send_ok: packet_header: Memory: 0x7f5d40085ca4  Bytes: (4)
07 00 00 01 
my_net_read_packet: packet_header: Memory: 0x775cf800bde0  Bytes: (4)
10 00 00 00 
Protocol::send_result_set_metadata: packet_header: Memory: 0x7f5d40084c04  Bytes: (4)
02 00 00 01 
Protocol::write: packet_header: Memory: 0x7f5d40084bb4  Bytes: (4)
1A 00 00 02 
Protocol::send_result_set_metadata: packet_header: Memory: 0x7f5d40084bb4  Bytes: (4)
05 00 00 03 
Protocol::write: packet_header: Memory: 0x7f5d40084db4  Bytes: (4)
02 00 00 04 
Protocol::net_send_eof: packet_header: Memory: 0x7f5d40084cf4  Bytes: (4)
05 00 00 05 
my_net_read_packet: packet_header: Memory: 0x775cf800bde0  Bytes: (4)
01 00 00 00 

Memory: 뒤의 주소는 실행마다 다릅니다. 읽어 보면 이렇습니다.

머리 누가 뜻
07 00 00 01 Protocol::net_send_ok 앞 문장 SET의 OK(본문 7바이트, 순번 1)
10 00 00 00 my_net_read_packet 받은 COM_QUERY — 본문 16바이트(명령 1 + SELECT 1 AS one 15), 순번 0
02 00 00 01 send_result_set_metadata 열 개수 패킷
1A 00 00 02 Protocol::write 열 정의 하나(26바이트)
05 00 00 03 send_result_set_metadata 열 정의의 끝을 알리는 EOF
02 00 00 04 Protocol::write 행 하나
05 00 00 05 Protocol::net_send_eof 결과 집합의 끝 EOF
01 00 00 00 my_net_read_packet 다음 명령 — 클라이언트가 나가며 보낸 COM_QUIT

명령을 받을 때마다 순번이 0으로 돌아가고, 서버의 답은 1부터 이어집니다. 이번에는 2천만 바이트짜리 값을 돌려받아 봅니다(클라이언트도 --max-allowed-packet=64M).

--- 2. a 20,000,000-byte value is sent as packets of 0xffffff + rest
20000005
Protocol::net_send_ok: packet_header: Memory: 0x7f5d40085ca4  Bytes: (4)
07 00 00 01 
my_net_read_packet: packet_header: Memory: 0x775cf800bde0  Bytes: (4)
24 00 00 00 
Protocol::send_result_set_metadata: packet_header: Memory: 0x7f5d40084c04  Bytes: (4)
02 00 00 01 
Protocol::write: packet_header: Memory: 0x7f5d40084bb4  Bytes: (4)
1A 00 00 02 
Protocol::send_result_set_metadata: packet_header: Memory: 0x7f5d40084bb4  Bytes: (4)
05 00 00 03 
Protocol::write: packet_header: Memory: 0x7f5d40084db4  Bytes: (4)
0A 2D 31 05 
Protocol::net_send_eof: packet_header: Memory: 0x7f5d40084cf4  Bytes: (4)
05 00 00 06 
my_net_read_packet: packet_header: Memory: 0x775cf800bde0  Bytes: (4)
01 00 00 00 

20000005는 받은 바이트 수입니다(값 2천만 + 머리글 big 줄과 줄 바꿈). 행 패킷 자리에 이번에는 0A 2D 31 05가 찍혔습니다. 길이 0x312d0a = 3,222,794바이트, 순번 5입니다. 행 본문은 값 2천만 바이트 앞에 길이 표시 9바이트(0xfe + 8바이트 정수)가 붙어 20,000,009바이트이고, 16,777,215바이트(순번 4)를 먼저 보낸 나머지가 3,222,794바이트입니다. 순번 4짜리 첫 조각의 머리(ff ff ff 04)는 찍히지 않았는데, DBUG_DUMP가 루프 밖, 마지막 조각을 쓰기 직전에만 있기 때문입니다(447–449행). 그래도 끝의 EOF 순번이 05에서 06으로 하나 밀린 것으로 조각이 하나 더 있었음을 알 수 있습니다.

마지막으로 서버의 max_allowed_packet을 1MB로 줄이고 2MB짜리 쿼리를 보냅니다.

--- 3. a query bigger than the server's max_allowed_packet
query length: 2000024
--------------
SELECT LENGTH('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
--------------

ERROR 2006 (HY000) at line 1: Server has gone away
Variable_name	Value
Aborted_clients	1
Aborted connection 8 to db: 'unconnected' user: 'root' host: 'localhost' (Got a packet bigger than 'max_allowed_packet' bytes)

서버의 net_realloc()이 ER_NET_PACKET_TOO_LARGE를 세우고 연결을 닫습니다. 클라이언트는 아직 2MB를 쓰는 중이라 오류 패킷을 읽지 못하고 ERROR 2006 … Server has gone away만 봤고, 서버 쪽에서는 Aborted_clients가 오르고 오류 로그에 Got a packet bigger than 'max_allowed_packet' bytes가 남았습니다. "큰 INSERT를 보냈더니 서버가 사라졌다"는 흔한 증상이 이것입니다.

헷갈리기 쉬운 곳: max_allowed_packet은 양쪽에 있습니다

이 한도는 서버만의 것이 아닙니다. sql/net_serv.cc는 클라이언트 라이브러리도 같이 쓰고, 클라이언트도 자기 max_allowed_packet(mariadb --max-allowed-packet)보다 큰 패킷은 받지 않습니다. 위 예제에서 클라이언트에도 64M을 준 이유입니다. 서버의 값만 올리고 큰 결과를 받으려다 클라이언트 쪽에서 막히는 일이 있습니다.

2. 손으로 말 걸어 보기 — 핸드셰이크와 Vio

클라이언트가 접속하면 서버가 먼저 말합니다. 인사 패킷(handshake)을 보내고, 클라이언트가 사용자 이름과 인증 데이터를 담아 답하고, 서버가 OK나 오류로 마무리합니다. 인증 플러그인과 acl_authenticate()는 4편의 몫이니, 여기서는 인사 패킷이 만들어지는 send_server_handshake_packet()의 앞부분만 봅니다.

  *end++= protocol_version;
  // ...
  end= strnmov(end, server_version, SERVER_VERSION_LENGTH) + 1;

  int4store((uchar*) end, mpvio->auth_info.thd->thread_id);
  end+= 4;
  // ...
  CHARSET_INFO *handshake_cs= default_charset_info;
  if (handshake_cs->number > 0xFF)
  {
    // ...
    handshake_cs= cs ? cs : &my_charset_latin1;
  }

  /* write server characteristics: up to 16 bytes allowed */
  end[2]= (char) handshake_cs->number;

  int2store(end+3, mpvio->auth_info.thd->server_status);

sql/sql_acl.cc 15773–15858행

프로토콜 버전(protocol_version, cmake/mysql_version.cmake에서 10), 서버 버전 문자열, 연결 ID(thread_id), 인증에 쓸 난수(scramble), 서버가 할 줄 아는 기능의 비트 묶음(capabilities), 문자 집합 번호, 서버 상태가 차례로 들어갑니다. 마지막에 my_net_write()와 net_flush()로 보냅니다.

예제 2: bash로 서버와 직접 말하기

클라이언트 라이브러리 없이 이 대화를 손으로 해 봤습니다. bash는 /dev/tcp/호스트/포트를 열면 TCP 연결을 만들어 줍니다. 비밀번호 없는 사용자 lab@'127.0.0.1'을 만들고, 받은 바이트는 od -A d -t x1z(10진 위치, 16진 바이트, 글자)로 찍었습니다. 예제 폴더는 ex/u05-handshake, 스크립트의 핵심은 이렇습니다(단계 이름을 찍는 echo 줄은 빼고, 단계 번호를 각 줄 끝 주석으로 옮겼습니다).

# 이 글을 위해 쓴 예제입니다 (MariaDB 의 소스가 아닙니다)
# ... (사용자 lab 만들기, 카운터 찍기 생략)
# 소켓에서 0.5초 동안 온 것을 모두 읽어 od 로 찍는다
dump() { timeout 0.5 cat <&3 > /tmp/in.bin || true; od -A d -t x1z -v /tmp/in.bin; }

exec 3<>/dev/tcp/127.0.0.1/3306
dump                                   # 1. 서버의 인사 패킷

# 2. 핸드셰이크 응답: 머리(길이 0x25, 순번 1), capabilities 0x00008200 (PROTOCOL_41|SECURE_CONNECTION),
#    max packet 16MB, charset 33, 0 23개, "lab\0", 비밀번호 길이 0
printf '\x25\x00\x00\x01''\x00\x82\x00\x00''\x00\x00\x00\x01''\x21' >&3
printf '\x00%.0s' $(seq 23) >&3
printf 'lab\x00''\x00' >&3
dump

printf '\x01\x00\x00\x00''\x0e' >&3                # 3. COM_PING
dump
printf '\x09\x00\x00\x00''\x03''SELECT 1' >&3      # 4. COM_QUERY
dump

먼저 서버의 인사 패킷입니다.

--- 0. collation_server=utf8mb4_uca1400_ai_ci (id 2304), id 45 = utf8mb4_general_ci
--- 0. counters before: Aborted_clients=0 Com_admin_commands=1 Com_select=1 Com_stmt_execute=0 Com_stmt_prepare=0 
--- 1. server greeting (handshake packet)
0000000 58 00 00 00 0a 31 33 2e 32 2e 30 2d 4d 61 72 69  >X....13.2.0-Mari<
0000016 61 44 42 2d 64 65 62 75 67 00 07 00 00 00 3a 6f  >aDB-debug.....:o<
0000032 42 3b 43 7a 60 51 00 fe ff 2d 02 00 ff 81 15 00  >B;Cz`Q...-......<
0000048 00 00 00 00 00 3d 00 00 00 54 34 47 45 64 4b 29  >.....=...T4GEdK)<
0000064 2a 35 6a 3b 27 00 6d 79 73 71 6c 5f 6e 61 74 69  >*5j;'.mysql_nati<
0000080 76 65 5f 70 61 73 73 77 6f 72 64 00              >ve_password.<
0000092

58 00 00 = 길이 88, 순번 00. 본문은 0a(프로토콜 10), 13.2.0-MariaDB-debug 뒤에 00, 연결 ID 07 00 00 00(= 7, 실행마다 다릅니다), scramble 앞 8바이트(실행마다 다릅니다)와 00, capabilities 아래 2바이트 fe ff, 문자 집합 2d, 서버 상태 02 00, capabilities 위 2바이트 ff 81, 그리고 맨 끝에 클라이언트가 쓸 인증 플러그인 이름 mysql_native_password가 보입니다.

두 군데가 눈에 띕니다.

이어서 로그인, COM_PING, COM_QUERY의 답입니다.

--- 2. handshake response: user 'lab', no password
0000000 07 00 00 02 00 00 00 02 00 00 00                 >...........<
0000011
--- 3. COM_PING (0x0e)
0000000 07 00 00 01 00 00 00 02 00 00 00                 >...........<
0000011
--- 4. COM_QUERY (0x03) 'SELECT 1'
0000000 01 00 00 01 01 17 00 00 02 03 64 65 66 00 00 00  >..........def...<
0000016 01 31 00 0c 3f 00 01 00 00 00 03 81 00 00 00 00  >.1..?...........<
0000032 05 00 00 03 fe 00 00 02 00 02 00 00 04 01 31 05  >..............1.<
0000048 00 00 05 fe 00 00 02 00                          >........<
0000056

로그인의 답은 순번 2의 OK 패킷입니다: 00(OK 표시), 영향받은 행 0, 마지막 insert id 0, 서버 상태 02 00(SERVER_STATUS_AUTOCOMMIT), 경고 수 00 00. 바이트를 만든 곳은 5절에서 볼 Protocol::net_send_ok()입니다. COM_PING의 답도 같은 OK인데 순번이 1입니다 — 명령을 받을 때마다 순번이 0으로 돌아갔기 때문입니다.

SELECT 1의 답은 패킷 다섯 개입니다.

순번 바이트 뜻
1 01 열 개수 1
2 03 64 65 66 … 열 정의: 카탈로그 def, 이름 1, 문자 집합 3f(63, binary), 길이 1, 형식 03(LONG) …
3 fe 00 00 02 00 EOF: 경고 0, 상태 02 00
4 01 31 행: 길이 1, 글자 '1'(0x31)
5 fe 00 00 02 00 EOF: 결과 집합 끝

정수 1이 글자 '1' 로 갔다는 것을 기억해 둡니다. 5절에서 준비된 문장과 비교합니다.

bash 클라이언트와 mariadbd의 대화. 서버가 순번 0의 인사 패킷(프로토콜 10, 버전, 연결 ID, scramble, capabilities)을 보내고, 클라이언트가 순번 1의 핸드셰이크 응답(capabilities, 사용자 lab, 비밀번호 길이 0)을 보내고, 서버가 순번 2의 OK로 답한다. 로그인이 끝난 뒤에는 명령마다 순번이 0부터 시작한다. COM_PING(순번 0)에는 OK(순번 1), COM_QUERY SELECT 1(순번 0)에는 열 개수(1), 열 정의(2), EOF(3), 행 01 31(4), EOF(5)가 온다. 마지막 COM_QUIT에는 답이 없고 연결이 닫힌다. 예제 2의 대화. 대괄호 안이 순번입니다.

Vio — 소켓을 함수 포인터로 감싸기

my_real_read()는 소켓을 직접 recv()하지 않고 vio_read()를 부릅니다. 이것은 함수가 아니라 ((vio)->read)(vio,buf,size)로 펼쳐지는 매크로입니다(include/violite.h 202행). Vio 구조체가 함수 포인터를 들고 있습니다.

  /* function pointers. They are similar for socket/SSL/whatever */
  void    (*viodelete)(Vio*);
  int     (*vioerrno)(Vio*);
  size_t  (*read)(Vio*, uchar *, size_t);
  size_t  (*write)(Vio*, const uchar *, size_t);
  int     (*timeout)(Vio*, uint, my_bool);

include/violite.h 255–260행

vio/vio.c의 vio_init()이 연결의 종류에 따라 이 포인터를 채웁니다. TCP·유닉스 소켓이면 vio_read(또는 버퍼를 쓰는 vio_read_buff)를(vio/vio.c 159행), SSL이면 vio/viossl.c의 함수를, 윈도의 이름 있는 파이프면 vio_read_pipe를 넣습니다. 그래서 NET의 코드는 SSL인지 아닌지 몰라도 됩니다. httpd 2편에서 본 "C 구조체에 함수 포인터를 담아 다형성을 흉내 내는" 방식과 같습니다.

TCP 소켓의 vio_read()(vio/viosocket.c 156행)는 읽기 타임아웃이 걸려 있으면 소켓을 논블로킹으로 읽고, 데이터가 없으면(EAGAIN) vio_socket_io_wait()로 poll()하며 타임아웃까지 기다립니다. 3절의 wait_timeout이 여기까지 내려옵니다.

3. do_command — 명령 하나 읽기

4편에서 본 연결 스레드는 do_handle_one_connection()의 while (thd_is_connection_alive(thd)) 루프에서 do_command(thd)를 되풀이합니다(sql/sql_connect.cc 1510–1516행). 스레드 풀에서도 워커가 같은 do_command()를 부릅니다(sql/threadpool_common.cc 438행, blocking=false). 어느 스케줄러든 명령 하나 = do_command() 한 번입니다. 그 안을 줄여서 보면 이렇습니다.

  if (!thd->skip_wait_timeout)
    my_net_set_read_timeout(net, thd->get_net_wait_timeout());
  // ...
  thd->clear_error(1);

  net_new_transaction(net);
  // ...
  packet_length= my_net_read_packet(net, 1);
  // ...
  packet= (char*) net->read_pos;
  // ...
  command= fetch_command(thd, packet);
  // ...
  my_net_set_read_timeout(net, thd->variables.net_read_timeout);

  DBUG_ASSERT(packet_length);
  DBUG_ASSERT(!thd->apc_target.is_enabled());

resume:
  return_value= dispatch_command(command, thd, packet+1,
                                 (uint) (packet_length-1), blocking);

sql/sql_parse.cc 1266–1438행

(WITH_WSREP — Galera 클러스터용 — 블록과 오류 처리는 줄였습니다.)

  1. 기다리기 전에 타임아웃을 wait_timeout으로 바꿉니다. thd->get_net_wait_timeout()은 보통 세션의 wait_timeout을, 트랜잭션이 열린 채 쉬고 있으면 idle_transaction_timeout 계열 값을 돌려줍니다(sql/sql_class.h 6491–6511행).
  2. 이전 문장의 오류·경고를 지우고(clear_error), 순번을 0으로 돌립니다. net_new_transaction(net)은 ((net)->pkt_nr=0) 한 줄짜리 매크로입니다(include/mysql_com.h 637행). 예제에서 명령마다 순번이 0부터 시작한 이유입니다.
  3. my_net_read_packet(net, 1)에서 다음 명령이 올 때까지 멈춰 있습니다. SHOW PROCESSLIST에서 Sleep으로 보이는 연결은 바로 이 줄에 있습니다.
  4. 패킷이 오면 첫 바이트를 명령으로 꺼내고(fetch_command), 타임아웃을 net_read_timeout으로 되돌린 뒤 dispatch_command()에 명령 번호와 첫 바이트를 뗀 나머지(packet+1, packet_length-1)를 넘깁니다.

예제 1에서 받은 COM_QUERY 패킷이 갈라지는 모습. 머리 10 00 00 00 은 본문 16바이트, 순번 0을 뜻하고 my_net_read_packet() 이 떼어 낸다. 본문 첫 바이트 packet[0] 은 0x03 이고 나머지 15바이트는 SELECT 1 AS one 이다. fetch_command() 가 0x03 을 COM_QUERY 로 바꾸고(COM_END 이상이거나 빈 번호면 COM_END), dispatch_command(command, thd, packet+1, packet_length-1) 에는 COM_QUERY, "SELECT 1 AS one", 15 가 넘어간다 명령 번호는 본문 첫 바이트 하나이고, dispatch_command() 는 그 바이트를 뗀 나머지를 받습니다. 바이트 값은 예제 1의 실제 출력(10 00 00 00)과 include/mysql_com.h 90행의 enum(COM_QUERY = 3)에서 가져왔습니다.

fetch_command()는 범위를 벗어난 값을 COM_END로 바꾸는 것이 전부입니다.

static enum enum_server_command fetch_command(THD *thd, char *packet)
{
  enum enum_server_command
    command= (enum enum_server_command) (uchar) packet[0];
  DBUG_ENTER("fetch_command");

  if (command >= COM_END ||
      (command >= COM_MDB_GAP_BEG && command <= COM_MDB_GAP_END))
    command= COM_END;				// Wrong command

sql/sql_parse.cc 1180–1188행

헷갈리기 쉬운 곳: wait_timeout은 "쉬는 시간"에만 셉니다

wait_timeout을 짧게 주면 오래 걸리는 쿼리가 끊길까요? 위 코드대로라면 아닙니다. wait_timeout은 my_net_read_packet()으로 다음 명령을 기다리는 동안에만 걸려 있고, 명령이 오면 곧바로 net_read_timeout으로 바뀝니다. 예제 폴더 ex/u05-commands의 5번에서 확인했습니다. wait_timeout=2인 세션에서 3초짜리 SLEEP(3)은 끝까지 돌고, 그 뒤 4초를 쉬었다가 보낸 문장은 끊깁니다.

--- 5. wait_timeout counts only while waiting for the next command
long_query_is_fine
0
--------------
SELECT 'after 4s idle' AS next
--------------

ERROR 2006 (HY000) at line 2: Server has gone away
Variable_name	Value
Aborted_clients	1
Aborted connection 14 to db: 'unconnected' user: 'root' host: 'localhost' (Got timeout reading communication packets)

서버 쪽의 my_real_read()가 타임아웃으로 ER_NET_READ_INTERRUPTED를 세우고(vio_was_timeout()), do_command()가 DISPATCH_COMMAND_CLOSE_CONNECTION을 돌려 연결 루프가 끝납니다. 오류 로그의 Got timeout reading communication packets가 그 메시지입니다(연결 ID 14는 실행마다 다릅니다).

wait_timeout=2 인 세션의 시간 순서. 연결 스레드는 do_command() 에서 다음 명령을 기다리는 동안 읽기 제한 시간을 wait_timeout(2초)으로 둔다. 클라이언트가 SELECT SLEEP(3) 을 보내면 곧바로 net_read_timeout 으로 바꾸고, 3초 동안 실행하는 동안에는 소켓을 읽지 않으므로 끊기지 않고 결과 0(long_query_is_fine)을 돌려준다. 다시 wait_timeout(2초)으로 기다리는데 클라이언트가 4초를 쉬므로, 2초가 지나 my_real_read() 가 시간 초과로 ER_NET_READ_INTERRUPTED 를 세우고 DISPATCH_COMMAND_CLOSE_CONNECTION 으로 연결이 끝난다. Aborted_clients 는 1, 오류 로그에는 Got timeout reading communication packets 가 남는다. 그 뒤 클라이언트가 보낸 SELECT 'after 4s idle' 은 ERROR 2006 Server has gone away 를 받는다 wait_timeout 은 명령과 명령 사이의 쉬는 시간에만 걸립니다. 순서와 메시지는 위 ex/u05-commands 5번의 실제 출력입니다.

4. dispatch_command — 명령 바이트로 갈라지는 switch

dispatch_command()는 명령마다 공통으로 할 일을 하고, 큰 switch로 갈라졌다가, dispatch_end라는 이름표에서 다시 만나 뒷정리를 합니다.

dispatch_command의 흐름. do_command가 wait_timeout을 걸고 my_net_read_packet으로 읽은 뒤 fetch_command가 packet[0]을 명령으로 꺼내 dispatch_command로 넘긴다. dispatch_command는 query_id, Questions, set_command를 처리하고 switch로 갈라진다. COM_QUERY는 mysql_parse(쿼리 캐시, parse_sql, mysql_execute_command)로, COM_STMT_PREPARE와 COM_STMT_EXECUTE는 sql_prepare.cc로, COM_PING과 COM_INIT_DB는 my_ok로 Diagnostics_area에 OK를 적고, 모르는 명령은 ER_UNKNOWN_COM_ERROR가 된다. COM_QUIT은 error를 TRUE로 하고 보낼 답이 없다고 표시한다(disable_status). 이들은 모두 dispatch_end에서 end_statement와 free_root를 거치고, error가 TRUE면 DISPATCH_COMMAND_CLOSE_CONNECTION을 돌려 연결 루프가 끝난다. 회색 칸(파싱 이후, 준비된 문장)은 6편부터 자세히 봅니다.

공통 머리

  if (command != COM_QUERY)
    thd->reset_for_next_command();
  thd->set_command(command);

  thd->enable_slow_log= true;
  thd->query_plan_flags= QPLAN_INIT;
  thd->lex->sql_command= SQLCOM_END; /* to avoid confusing VIEW detectors */
  thd->reset_kill_query();

  DEBUG_SYNC(thd,"dispatch_command_before_set_time");

  thd->set_time();
  if (!(server_command_flags[command] & CF_SKIP_QUERY_ID))
    thd->set_query_id(next_query_id());
  // ...
  if (!(server_command_flags[command] & CF_SKIP_QUESTIONS))
    statistic_increment(thd->status_var.questions, &LOCK_status);
  // ...
  switch (command) {

sql/sql_parse.cc 1654–1713행

짧은 명령들: COM_PING, COM_QUIT

  case COM_PING:
    status_var_increment(thd->status_var.com_other);
    my_ok(thd);				// Tell client we are alive
    break;

sql/sql_parse.cc 2312–2315행

COM_PING은 할 일이 없습니다. Com_admin_commands(소스의 com_other)를 하나 올리고 my_ok()로 Diagnostics_area에 OK를 적을 뿐입니다. 보내는 것은 dispatch_end의 end_statement()가 합니다. mariadb-admin ping이 보내는 것이 이 명령입니다(예제 3).

  case COM_QUIT:
    /* Note: We don't calculate statistics for this command */

    /* Ensure that quit works even if max_mem_used is set */
    thd->variables.max_mem_used= LONGLONG_MAX;
    general_log_print(thd, command, NullS);
    net->error=0;				// Don't give 'abort' message
    thd->get_stmt_da()->disable_status();       // Don't send anything back
    error=TRUE;					// End server
    break;

sql/sql_parse.cc 2122–2131행

COM_QUIT은 disable_status()로 "보낼 답 없음"을 표시하고, error=TRUE로 연결을 닫게 합니다. net->error=0이 "정상 종료"라는 표시라서, 이렇게 나간 연결은 Aborted_clients에 세지 않습니다. 예제 2에서 COM_QUIT에 아무 답이 없었고 Aborted_clients가 0으로 남은 것이 이 코드입니다.

COM_QUERY — SQL 문장 하나(또는 여럿)

  case COM_QUERY:
    // ...
    if (unlikely(alloc_query(thd, packet, packet_length)))
      break;					// fatal error is set
    // ...
    general_log_write(thd, command, thd->query(), thd->query_length());
      // ...
      mysql_parse(thd, thd->query(), thd->query_length(), &parser_state);

    while (!thd->killed && (parser_state.m_lip.found_semicolon != NULL) &&
           ! thd->is_error())
    {
      // ...
      char *beginning_of_next_stmt= (char*) parser_state.m_lip.found_semicolon;
      // ...
      thd->protocol->end_statement();
      // ...
      statistic_increment(thd->status_var.questions, &LOCK_status);
      // ...
      mysql_parse(thd, beginning_of_next_stmt, length, &parser_state);

sql/sql_parse.cc 1862–2002행

(WITH_WSREP 블록과 performance_schema·DTrace 계측은 줄였습니다.)

  1. alloc_query()가 패킷의 SQL 문자열을 THD의 메모리로 복사합니다. general_log_write()가 일반 쿼리 로그(general_log)에 남기는 곳도 여기, 파싱하기 전입니다. 그래서 문법이 틀린 문장도 일반 로그에는 남습니다.
  2. mysql_parse()가 파싱하고 실행합니다. 안쪽은 6편부터 봅니다.
  3. 패킷 하나에 문장이 여럿 들어 있으면(SELECT 1; SELECT 2처럼 — 클라이언트가 CLIENT_MULTI_STATEMENTS를 켰을 때) 파서가 첫 문장 끝의 ; 위치를 found_semicolon에 남깁니다. 그러면 while 루프가 이번 문장의 답을 보내고(end_statement), Questions를 하나 더 올리고, 다음 문장으로 mysql_parse()를 다시 부릅니다.

예제 3: 명령과 상태 변수

예제 폴더 ex/u05-commands입니다. mariadb 클라이언트는 보통 ;마다 문장을 잘라 따로 보내므로, DELIMITER //로 바꿔 세 문장을 패킷 하나에 실었습니다. 일반 로그는 log_output='TABLE'로 켜서 mysql.general_log에서 읽었습니다. 아래는 run.sh 안의 SQL 가운데 1번 부분입니다(단계 이름을 찍는 SELECT와 주석 한 줄은 뺐습니다).

-- 이 글을 위해 쓴 예제입니다 (MariaDB 의 소스가 아닙니다)
-- ...
FLUSH STATUS;
DELIMITER //
SELECT 1; SELECT 2; DO 3 //
DELIMITER ;
SHOW SESSION STATUS WHERE Variable_name IN ('Com_select','Com_do','Questions');
SELECT command_type, argument FROM mysql.general_log
 WHERE thread_id = CONNECTION_ID() AND argument LIKE 'SELECT 1%';
+---------------+-------+
 Variable_name | Value |
+---------------+-------+
 Com_do        | 1     |
 Com_select    | 2     |
 Questions     | 4     |
+---------------+-------+
+--------------+--------------------------+
 command_type | argument                 |
+--------------+--------------------------+
 Query        | SELECT 1; SELECT 2; DO 3 |
+--------------+--------------------------+

일반 로그에는 Query 하나로 세 문장이 통째로 남았고(1875행은 루프 밖에 있으니까요), Questions는 세 문장 각각에 SHOW STATUS까지 더해 4입니다. Com_select·Com_do처럼 SQL 종류마다 세는 Com_*는 mysql_execute_command()가 문장마다 올립니다(sql/sql_parse.cc 3789행).

mariadb-admin ping과 SHOW PROCESSLIST도 봤습니다.

--- 3. mariadb-admin ping sends COM_PING
mysqld is alive
Com_admin_commands: 1 -> 2
--- 4. the Command column of SHOW PROCESSLIST is command_name[]
+----+---------+------------+-----------------+
 ID | COMMAND | STATE      | INFO            |
+----+---------+------------+-----------------+
 11 | Sleep   |            | NULL            |
 12 | Query   | User sleep | SELECT SLEEP(3) |
+----+---------+------------+-----------------+

COM_PING은 Com_admin_commands를 올립니다. Com_ping 같은 변수는 없습니다. PROCESSLIST에서 다음 명령을 기다리는 연결은 Sleep, SLEEP(3)을 실행 중인 연결은 Query입니다. Command 열은 SQL의 종류가 아니라 프로토콜 명령의 이름입니다(ID는 실행마다 다릅니다).

예제 4: dispatch_command까지의 호출 스택

gdb로 dispatch_command에 중단점을 걸고 SELECT id FROM t WHERE id >= 2를 보냈습니다(예제 폴더 ex/u05-gdb). 주소와 인자는 지웠습니다.

=== 1. break dispatch_command
#0  dispatch_command at /usr/src/mariadb/sql/sql_parse.cc:1613
#1  do_command at /usr/src/mariadb/sql/sql_parse.cc:1437
#2  do_handle_one_connection at /usr/src/mariadb/sql/sql_connect.cc:1514
#3  handle_one_connection at /usr/src/mariadb/sql/sql_connect.cc:1426
#4  pfs_spawn_thread at /usr/src/mariadb/storage/perfschema/pfs.cc:2198
#5  ?? () from /lib/x86_64-linux-gnu/libc.so.6
#6  ?? () from /lib/x86_64-linux-gnu/libc.so.6
$1 = COM_QUERY
$2 = 0x778f6c00bde1 "SELECT id FROM t WHERE id >= 2"

handle_one_connection → do_handle_one_connection(1514행, 위의 while 루프) → do_command(1437행) → dispatch_command. command는 COM_QUERY이고, packet은 명령 바이트를 뗀 SQL 문자열 그대로입니다(주소는 실행마다 다릅니다). 2편 지도의 ①에서 ②로 넘어가는 자리입니다.

헷갈리기 쉬운 곳: SQL의 PREPARE와 COM_STMT_PREPARE

준비된 문장을 만드는 길은 둘입니다. 프로토콜 명령 COM_STMT_PREPARE를 보내는 길(드라이버의 서버 쪽 prepared statement)과, SQL 문장 PREPARE s FROM '…'을 COM_QUERY로 보내는 길입니다. 상태 변수도 따로 있어서 앞쪽은 Com_stmt_prepare, 뒤쪽은 Com_prepare_sql입니다. 그런데 SQL PREPARE도 결국 Prepared_statement::prepare()를 부르고, 그 함수가 com_stmt_prepare를 언제나 올립니다(sql/sql_prepare.cc 4475행, EXECUTE도 5433행에서 같습니다). 예제 3의 2번입니다.

+------------------+-------+
 Variable_name    | Value |
+------------------+-------+
 Com_execute_sql  | 1     |
 Com_prepare_sql  | 1     |
 Com_select       | 1     |
 Com_stmt_execute | 1     |
 Com_stmt_prepare | 1     |
+------------------+-------+

SQL PREPARE/EXECUTE 한 번에 Com_stmt_*도 함께 올랐습니다. "Com_stmt_prepare가 있으니 드라이버가 서버 쪽 prepared statement를 쓰고 있다"고 단정하면 안 되는 이유입니다.

5. 결과 보내기 — Protocol과 Diagnostics_area

이제 반대쪽, 답이 나가는 길입니다. THD는 프로토콜 객체를 두 개 들고 있고, protocol 포인터가 지금 쓸 쪽을 가리킵니다.

  NET	  net;				// client connection descriptor
  /** Additional network instrumentation for the server only. */
  NET_SERVER m_net_server_extension;
  scheduler_functions *scheduler;       // Scheduler for this connection
  Protocol *protocol;			// Current protocol
  Protocol_text   protocol_text;	// Normal protocol
  Protocol_binary protocol_binary;	// Binary protocol

sql/sql_class.h 3401–3407행

결과를 보내는 객체들. THD가 NET net, Protocol 포인터 protocol, Protocol_text protocol_text, Protocol_binary protocol_binary를 가지고 get_stmt_da()로 Diagnostics_area를 가리킨다. NET은 Vio 포인터, 버퍼(buff, write_pos, read_pos), pkt_nr, max_packet_size를 가지고 Vio를 가리킨다. Vio는 종류(TCPIP, SOCKET, SSL)와 read·write 함수 포인터를 가진다. Protocol은 send_result_set_metadata, store_long, write, end_statement를 가지며 Protocol_text(숫자를 글자로)와 Protocol_binary(4바이트 그대로)가 이를 상속한다. Diagnostics_area는 status(OK, EOF, ERROR), affected_rows, sql_errno를 가진다. 위에서 아래로: 무엇을 보낼지(Protocol, Diagnostics_area) → 패킷(NET) → 바이트(Vio).

기본값은 protocol_text입니다(THD::THD의 protocol= &protocol_text;, sql/sql_class.cc 983행). COM_STMT_PREPARE를 처리하는 mysqld_stmt_prepare()는 thd->protocol= &thd->protocol_binary;로 잠깐 바이너리 쪽으로 바꿔 stmt->prepare()를 부르고, 끝나면 save_protocol로 되돌립니다(sql/sql_prepare.cc 2695–2720행).

행 하나: 텍스트와 바이너리

결과 행은 실행기(9편)가 select_send::send_data()를 행마다 불러 보냅니다.

int select_send::send_data(List<Item> &items)
{
  Protocol *protocol= thd->protocol;
  DBUG_ENTER("select_send::send_data");

  protocol->prepare_for_resend();
  if (protocol->send_result_set_row(&items))
  {
    protocol->remove_last_row();
    DBUG_RETURN(TRUE);
  }

  thd->inc_sent_row_count(1);

  /* Don't return error if disconnected, only if write fails */
  if (likely(thd->vio_ok()))
    DBUG_RETURN(protocol->write());

  DBUG_RETURN(0);
}

sql/sql_class.cc 3477–3496행

send_result_set_row()가 열마다 Item::send()를 부르고, 그 안에서 값의 형식에 맞는 store_*()가 불립니다. 정수(INT) 하나를 담는 store_long()이 두 프로토콜에서 어떻게 다른지 보면 차이가 한눈에 보입니다.

bool Protocol_text::store_long(longlong from)
{
#ifndef DBUG_OFF
  DBUG_ASSERT(valid_handler(field_pos, PROTOCOL_SEND_LONG));
  field_pos++;
#endif
  char buff[22];
  size_t length= (size_t) (int10_to_str((long int)from, buff,
                                        (from < 0) ? - 10 : 10) - buff);
  return store_numeric_string_aux(buff, length);
}

sql/protocol.cc 1543–1553행

bool Protocol_binary::store_long(longlong from)
{
  field_pos++;
  char *to= packet->prep_append(4, PACKET_BUFFER_EXTRA_ALLOC);
  if (!to)
    return 1;
  int4store(to, from);
  return 0;
}

sql/protocol.cc 1779–1787행

텍스트 쪽은 int10_to_str()로 10진 글자를 만들어 "길이 + 글자"로 붙이고, 바이너리 쪽은 int4store()로 리틀 엔디언 4바이트를 그대로 붙입니다. 바이너리 행은 앞에 00 한 바이트와 NULL 비트맵이 더 붙습니다(protocol.cc 1704–1720행의 주석).

예제 5: 같은 SELECT 1, 텍스트와 바이너리

예제 2에 이어서 같은 연결에서 COM_STMT_PREPARE(0x16)로 SELECT 1을 준비하고, COM_STMT_EXECUTE(0x17)로 실행했습니다.

# 이 글을 위해 쓴 예제입니다 (MariaDB 의 소스가 아닙니다)
# ... (예제 2에 이어서, 같은 연결)
printf '\x09\x00\x00\x00''\x16''SELECT 1' >&3                                       # 5. COM_STMT_PREPARE
dump
printf '\x0a\x00\x00\x00''\x17''\x01\x00\x00\x00''\x00''\x01\x00\x00\x00' >&3      # 6. COM_STMT_EXECUTE
dump                                                                               #    stmt_id=1, flags=0, iterations=1
--- 5. COM_STMT_PREPARE (0x16) 'SELECT 1'
0000000 0c 00 00 01 00 01 00 00 00 01 00 00 00 00 00 00  >................<
0000016 17 00 00 02 03 64 65 66 00 00 00 01 31 00 0c 3f  >.....def....1..?<
0000032 00 01 00 00 00 03 81 00 00 00 00 05 00 00 03 fe  >................<
0000048 00 00 02 00                                      >....<
0000052
--- 6. COM_STMT_EXECUTE (0x17) stmt_id=1, flags=0, iterations=1
0000000 01 00 00 01 01 17 00 00 02 03 64 65 66 00 00 00  >..........def...<
0000016 01 31 00 0c 3f 00 01 00 00 00 03 81 00 00 00 00  >.1..?...........<
0000032 05 00 00 03 fe 00 00 02 00 06 00 00 04 00 00 01  >................<
0000048 00 00 00 05 00 00 05 fe 00 00 02 00              >............<
0000060
--- 7. COM_QUIT (0x01)
0000000
--- 8. counters after:  Aborted_clients=0 Com_admin_commands=2 Com_select=3 Com_stmt_execute=1 Com_stmt_prepare=1 

문장의 최종 답: Diagnostics_area와 end_statement()

결과 행은 실행 중에 바로바로 버퍼로 가지만, 문장의 마지막 패킷(OK, EOF, 오류)은 다르게 다룹니다. 실행하는 코드는 Diagnostics_area에 상태만 적습니다. 상태는 enum_diagnostics_status(sql/sql_error.h 1021–1037행)의 일곱 가지 — 문장 시작 때의 DA_EMPTY, DA_OK, DA_EOF, 묶음 실행용 DA_OK_BULK·DA_EOF_BULK, DA_ERROR, 그리고 COM_STMT_PREPARE처럼 답을 직접 보낸 경우의 DA_DISABLED — 입니다. my_ok()는 영향받은 행 수를 THD에 적고 thd->get_stmt_da()->set_ok_status(…)를 부르는 인라인 함수이고(sql/sql_class.h 6710–6717행), my_eof()가 DA_EOF를, my_error()가 DA_ERROR를 적습니다.

그리고 dispatch_command()의 끝 dispatch_end에서 Protocol::end_statement()가 그 상태를 보고 알맞은 패킷 하나를 보냅니다.

  if (do_end_of_statement)
  {
    DBUG_ASSERT(thd->derived_tables == NULL &&
               (thd->open_tables == NULL ||
               (thd->locked_tables_mode == LTM_LOCK_TABLES)));

    thd_proc_info(thd, "Updating status");
    /* Finalize server status flags after executing a command. */
    thd->update_server_status();
    thd->protocol->end_statement();
    query_cache_end_of_result(thd);
  }
  // ...
  thd->lex->m_sql_cmd= NULL;
  free_root(thd->mem_root,MYF(MY_KEEP_PREALLOC));

sql/sql_parse.cc 2439–2494행

  if (thd->get_stmt_da()->is_sent())
    DBUG_VOID_RETURN;

  switch (thd->get_stmt_da()->status()) {
  case Diagnostics_area::DA_ERROR:
    thd->stop_collecting_unit_results();
    /* The query failed, send error to log and abort bootstrap. */
    error= send_error(thd->get_stmt_da()->sql_errno(),
                      thd->get_stmt_da()->message(),
                      thd->get_stmt_da()->get_sqlstate());
    break;
      // ...
      error= send_ok(thd->server_status,
                   thd->get_stmt_da()->statement_warn_count(),
                   thd->get_stmt_da()->affected_rows(),
                   thd->get_stmt_da()->last_insert_id(),
                   thd->get_stmt_da()->message());
    break;
  case Diagnostics_area::DA_DISABLED:
    break;

sql/protocol.cc 586–635행

(DA_EOF 분기와, 묶음 실행(bulk)의 결과를 모아 보내는 분기는 줄였습니다. DA_EOF면 send_eof()를 부릅니다.)

왜 마지막 패킷만 이렇게 미뤄 둘까요? end_statement() 위의 긴 주석이 답합니다(sql/protocol.cc 510–544행). 결과 행은 몇 개가 될지 모르니 서버에 모아 둘 수 없지만, 마지막 EOF·OK·오류는 Diagnostics_area에 담아 두었다가 실행이 다 끝난 뒤에 보낸다 — 그래야 커밋이 실패하거나 뒷정리 중에 오류가 나면 EOF를 오류로 바꿀 수 있기 때문이라고 적혀 있습니다. 그래서 실행 도중 여러 곳에서 상태를 적어도 클라이언트에게 가는 마지막 답은 한 번뿐입니다(is_sent()가 이미 참이면 다시 보내지 않습니다). 주석은 하나를 더 적어 둡니다. 실제 전송을 가상 함수 send_ok()·send_eof()·send_error()에 맡겨, 답을 소켓 대신 메모리에 담는 프로토콜도 끼울 수 있게 했다는 것입니다. ANALYZE 문장이 결과 행을 버리려고 잠깐 바꿔 끼우는 Protocol_discard(sql/sql_parse.cc 6257행)가 그런 예입니다. 마지막 줄 free_root(thd->mem_root, MYF(MY_KEEP_PREALLOC))는 문장 하나 동안 쓴 메모리를 한꺼번에 돌려줍니다 — 2편에서 본 MEM_ROOT의 문장 단위 수명이 여기서 끝납니다.

OK 패킷의 본문은 Protocol::net_send_ok()가 만듭니다.

  if (is_eof)
  {
    DBUG_ASSERT(thd->client_capabilities & CLIENT_DEPRECATE_EOF);
    store.q_append((char)254);
  }
  else
    store.q_append('\0');

  /* affected rows */
  store.q_net_store_length(affected_rows);

  /* last insert id */
  store.q_net_store_length(id);
  // ...
  error= my_net_write(net, (const unsigned char*)store.ptr(), store.length());
  if (likely(!error))
    error= net_flush(net);

sql/protocol.cc 227–283행

예제 2의 OK 00 00 00 02 00 00 00이 정확히 이 순서입니다. net_store_length()로 적는 숫자는 251보다 작으면 1바이트, 크면 fc·fd·fe 표시 뒤에 2·3·8바이트를 붙이는 "길이 인코딩 정수"입니다(sql-common/pack.c 131–154행). 예제 1에서 2천만 바이트 값 앞에 붙은 9바이트가 fe + 8바이트입니다. OK를 쓰고 나면 바로 net_flush()합니다.

SELECT 결과가 나가는 순서. select_send가 Protocol_text의 send_result_set_metadata를 부르고, Protocol_text는 열 개수, 열 정의, EOF를 my_net_write로 NET 버퍼에 쓴다. 행마다 do_select가 select_send의 send_data를 부르면 send_result_set_row와 store 함수들이 행을 만들고 write와 my_net_write로 버퍼에 넣는다. 16KB(net_buffer_length)가 찰 때까지는 버퍼에 쌓아 둔다. 끝에 do_select가 send_eof를 부르면 my_eof가 상태를 적고 end_statement와 send_eof가 write_eof_packet과 net_flush를 부른다. 그때 net_real_write가 vio_write로 소켓에 쓴다. 결과 패킷은 버퍼에 쌓였다가, 버퍼가 차거나 마지막 EOF를 쓸 때 한꺼번에 나갑니다.

예제 6: 바이트가 실제로 나가는 곳

같은 ex/u05-gdb에서 이번에는 net_real_write에 중단점을 걸고 같은 SELECT를 한 번 더 보냈습니다. 연결과 로그인은 gdb를 붙이기 전에 끝내 두었으므로, 처음 걸리는 것이 이 SELECT의 답입니다.

=== 2. break net_real_write (the bytes leave here)
#0  net_real_write at /usr/src/mariadb/sql/net_serv.cc:634
#1  net_flush at /usr/src/mariadb/sql/net_serv.cc:387
#2  Protocol::net_send_eof at /usr/src/mariadb/sql/protocol.cc:347
#3  Protocol::send_eof at /usr/src/mariadb/sql/protocol.cc:678
#4  Protocol::end_statement at /usr/src/mariadb/sql/protocol.cc:614
#5  select_send::send_eof at /usr/src/mariadb/sql/sql_class.cc:3533
#6  do_select at /usr/src/mariadb/sql/sql_select.cc:25141
#7  JOIN::exec_inner at /usr/src/mariadb/sql/sql_select.cc:5147
#8  JOIN::exec at /usr/src/mariadb/sql/sql_select.cc:4935
#9  mysql_select at /usr/src/mariadb/sql/sql_select.cc:5461
#10 handle_select at /usr/src/mariadb/sql/sql_select.cc:652
#11 execute_sqlcom_select at /usr/src/mariadb/sql/sql_parse.cc:6266
#12 mysql_execute_command at /usr/src/mariadb/sql/sql_parse.cc:4038
#13 mysql_parse at /usr/src/mariadb/sql/sql_parse.cc:8005
#14 dispatch_command at /usr/src/mariadb/sql/sql_parse.cc:1903
#15 do_command at /usr/src/mariadb/sql/sql_parse.cc:1437
#16 do_handle_one_connection at /usr/src/mariadb/sql/sql_connect.cc:1514
#17 handle_one_connection at /usr/src/mariadb/sql/sql_connect.cc:1426
#18 pfs_spawn_thread at /usr/src/mariadb/storage/perfschema/pfs.cc:2198
#19 ?? () from /lib/x86_64-linux-gnu/libc.so.6
#20 ?? () from /lib/x86_64-linux-gnu/libc.so.6
$1 = 72

두 가지를 확인할 수 있습니다.

  1. 열 개수, 열 정의 하나, EOF, 행 두 개, 마지막 EOF까지 72바이트가 net_real_write() 한 번에 나갔습니다(len = 72). 앞의 패킷들은 모두 net->buff에 쌓여 있다가, EOF를 쓴 net_send_eof()의 net_flush()에서 처음으로 net_real_write()가 불렸습니다.
  2. EOF를 do_select() 안에서 보냅니다. #5 select_send::send_eof(3533행)가 end_statement()를 직접 불렀습니다. SELECT 문장이 데이터를 바꾸지 않았으면(!thd->transaction->stmt.is_trx_read_write(), Galera 연결은 제외) select_send::send_eof()가 뒷정리(테이블 닫기, 잠금 풀기)를 기다리지 않고 답부터 보냅니다 — 주석이 "Send the result to the client so that it can process the data while the server is doing cleanups"라고 이유를 적어 두었습니다(sql/sql_class.cc 3524–3534행). 그러고 나면 is_sent()가 참이 되어 dispatch_end의 end_statement()는 아무것도 하지 않습니다.

헷갈리기 쉬운 곳: 열 정의 뒤의 EOF는 클라이언트에 따라 없습니다

예제의 답에는 열 정의 뒤에 EOF(fe)가 있었습니다. 클라이언트가 CLIENT_DEPRECATE_EOF를 켜고 접속하면 서버는 열 정의 뒤의 EOF를 보내지 않고(send_result_set_metadata(), sql/protocol.cc 1250–1261행), 결과 끝의 EOF 대신 fe로 시작하는 OK 패킷을 보냅니다(net_send_eof() 333–339행). 예제 2는 그 비트를 켜지 않았고, 예제 1의 mariadb 클라이언트도 끝을 Protocol::net_send_eof의 5바이트 EOF로 받았으니 켜지 않은 것입니다. 패킷을 직접 해석하는 코드를 쓸 때 걸리기 쉬운 곳입니다.

6. 쿼리 캐시 — 파싱보다 먼저, 문자열로

쿼리 캐시(query cache)는 SELECT의 결과 패킷을 쿼리 문자열을 열쇠로 저장해 두었다가, 똑같은 문자열이 오면 파싱도 실행도 하지 않고 저장된 패킷을 그대로 다시 보내는 기능입니다. 그래서 들어가는 자리가 파서보다 앞, mysql_parse()의 첫머리입니다.

  lex_start(thd);
  thd->reset_for_next_command();

  if (query_cache_send_result_to_client(thd, rawbuf, length) <= 0)
  {
    LEX *lex= thd->lex;

    bool err= parse_sql(thd, parser_state, NULL, true);
  // ...
  else
  {
    /* Update statistics for getting the query from the cache */
    thd->lex->sql_command= SQLCOM_SELECT;
    thd->m_statement_psi=
      MYSQL_REFINE_STATEMENT(thd->m_statement_psi,
                             sql_statement_info[SQLCOM_SELECT].m_key);
    status_var_increment(thd->status_var.com_stat[SQLCOM_SELECT]);

sql/sql_parse.cc 7950–8038행

query_cache_send_result_to_client()가 1을 돌려주면(찾았고 보냈으면) parse_sql()도 mysql_execute_command()도 부르지 않습니다. 대신 sql_command를 SQLCOM_SELECT로 정하고 Com_select를 올립니다. 바로 위의 긴 주석(7934–7949행)은 "자연스럽게는 캐시를 먼저 보고 파서를 준비해야 하지만, 캐시가 lex_start()와 reset_for_next_command()가 비운 상태에 기대고 있어서 렉서를 먼저 초기화한다"고 털어놓습니다.

캐시를 찾는 Query_cache::send_result_to_client()는 파서 없이 문자열만 봅니다.

  if ((my_toupper(system_charset_info, sql[0]) != 'S' ||
       my_toupper(system_charset_info, sql[1]) != 'E' ||
       my_toupper(system_charset_info, sql[2]) != 'L') &&
      (my_toupper(system_charset_info, sql[0]) != 'W' ||
       my_toupper(system_charset_info, sql[1]) != 'I' ||
       my_toupper(system_charset_info, sql[2]) != 'T'))
  {
    DBUG_PRINT("qcache", ("The statement is not a SELECT; Not cached"));
    goto err;
  }

sql/sql_cache.cc 1835–1844행

앞의 주석과 공백, 여는 괄호를 건너뛴 뒤 첫 세 글자가 SEL이나 WIT(WITH …)가 아니면 바로 포기합니다. 그다음 열쇠를 만듭니다 — 쿼리 문자열 바이트 + 현재 데이터베이스 이름 + 플래그 묶음(Query_cache_query_flags)입니다. 플래그에는 클라이언트 capabilities, 프로토콜 종류(텍스트/바이너리), 문자 집합 세 가지, sql_mode, 시간대, autocommit, 지금 순번까지 들어갑니다(1928–1955행). 결과가 패킷 그대로 저장되니, 같은 바이트를 그대로 돌려줘도 되는 조건을 전부 열쇠에 넣은 것입니다. 그 열쇠로 해시를 찾습니다.

  query_block = (Query_cache_block *)  my_hash_search(&queries, (uchar*) sql,
                                                      tot_length);

sql/sql_cache.cc 1989–1990행

저장하는 쪽은 두 군데에 나뉘어 있습니다. 실행 직전에 execute_sqlcom_select()가 query_cache_store_query()로 "이 문장을 캐시할 수 있으면 자리를 잡아 두라"고 하고(sql/sql_parse.cc 6265행), 결과 패킷이 소켓으로 나갈 때마다 net_real_write()가 그 바이트를 캐시에 복사합니다.

#if defined(MYSQL_SERVER)
  THD *thd= (THD *)net->thd;
#if defined(USE_QUERY_CACHE)
  query_cache_insert(thd, (char*) packet, len, net->pkt_nr);
#endif

sql/net_serv.cc 637–641행

결과가 다 나가면 dispatch_command()의 query_cache_end_of_result()(2449행)가 저장을 마무리합니다. 결과를 따로 만들지 않고 나가는 패킷을 엿들어 복사한다는 것이 이 설계의 핵심입니다. 찾았을 때 send_data_in_chunks()로 그 바이트를 그대로 다시 쓰는 것도 같은 이유입니다.

쿼리 캐시의 흐름. mysql_parse가 lex_start와 reset_for_next_command 뒤에 send_result_to_client를 부르면, SEL이나 WIT로 시작하는지 보고 쿼리 바이트와 현재 DB와 플래그로 열쇠를 만들어 해시를 찾는다. 있으면 Qcache_hits를 올리고 저장된 패킷을 send_data_in_chunks로 그대로 다시 보내며, 파서와 옵티마이저와 엔진을 건너뛰지만 Com_select는 오른다. 없으면 parse_sql과 mysql_execute_command로 가고, execute_sqlcom_select의 store_query가 캐시할 수 있으면 자리를 잡고, net_real_write의 query_cache_insert가 나가는 패킷을 복사하고, end_of_result가 결과가 다 모이면 저장을 끝내 쿼리 캐시(query_cache_size)에 들어간다. 노란 칸이 캐시 메모리, 초록 칸이 찾았을 때의 지름길입니다.

기본값은 꺼짐입니다.

static Sys_var_enum Sys_query_cache_type(
       "query_cache_type",
       "OFF = Don't cache or retrieve results. ON = Cache all results "
       "except SELECT SQL_NO_CACHE ... queries. DEMAND = Cache only "
       "SELECT SQL_CACHE ... queries",
       NO_SET_STMT SESSION_VAR(query_cache_type), CMD_LINE(REQUIRED_ARG),
       query_cache_type_names, DEFAULT(0), NO_MUTEX_GUARD, NOT_IN_BINLOG,

sql/sys_vars.cc 3596–3602행

query_cache_type_names[]는 { "OFF", "ON", "DEMAND", 0 }(3554행)이니 DEFAULT(0)은 OFF입니다. 크기 query_cache_size의 기본값은 1MB(DEFAULT(1024*1024), 3528행)인데, 설정 파일에서 크기만 따로 주면 기동할 때 타입을 ON(1)으로 올려 줍니다 — 주석이 "if we set size of QC non zero in config then probably we want it ON"입니다(sql/mysqld.cc 5228–5234행).

예제 7: 쿼리 캐시를 켜고 맞혀 보기

예제 폴더 ex/u05-query-cache입니다. 서버를 기본값으로 띄우고 SET GLOBAL query_cache_type = ON으로 켠 뒤, 문장을 하나씩 보내며 상태 변수를 찍었습니다.

--- 1. defaults
+--------------------+--------------------+------------------------------+
 @@query_cache_type | @@query_cache_size | @@query_cache_strip_comments |
+--------------------+--------------------+------------------------------+
 OFF                |            1048576 |                            0 |
+--------------------+--------------------+------------------------------+
--- 3. same text twice, then texts that differ only in case / spaces / comments
  Com_select=2 Qcache_hits=0 Qcache_inserts=0 Qcache_queries_in_cache=0 
> SELECT name FROM t WHERE id = 2
  Com_select=3 Qcache_hits=0 Qcache_inserts=1 Qcache_queries_in_cache=1 
> SELECT name FROM t WHERE id = 2
  Com_select=4 Qcache_hits=1 Qcache_inserts=1 Qcache_queries_in_cache=1 
> select name from t where id = 2
  Com_select=5 Qcache_hits=1 Qcache_inserts=2 Qcache_queries_in_cache=2 
> SELECT name FROM t WHERE id =  2
  Com_select=6 Qcache_hits=1 Qcache_inserts=3 Qcache_queries_in_cache=3 
> SELECT /* hi */ name FROM t WHERE id = 2
  Com_select=7 Qcache_hits=1 Qcache_inserts=4 Qcache_queries_in_cache=4 
--- 4. a write to the table invalidates its cached results
> UPDATE t SET name = 'TWO' WHERE id = 2
  Com_select=7 Qcache_hits=1 Qcache_inserts=4 Qcache_queries_in_cache=0 
> SELECT name FROM t WHERE id = 2
  Com_select=8 Qcache_hits=1 Qcache_inserts=5 Qcache_queries_in_cache=1 
> SELECT name FROM t WHERE id = 2
  Com_select=9 Qcache_hits=2 Qcache_inserts=5 Qcache_queries_in_cache=1 
--- 5. not cacheable: NOW(), SQL_NO_CACHE
> SELECT NOW(), name FROM t WHERE id = 1
  Com_select=10 Qcache_hits=2 Qcache_inserts=5 Qcache_queries_in_cache=1 
> SELECT NOW(), name FROM t WHERE id = 1
  Com_select=11 Qcache_hits=2 Qcache_inserts=5 Qcache_queries_in_cache=1 
> SELECT SQL_NO_CACHE name FROM t WHERE id = 1
  Com_select=12 Qcache_hits=2 Qcache_inserts=5 Qcache_queries_in_cache=1 
> SELECT SQL_NO_CACHE name FROM t WHERE id = 1
  Com_select=13 Qcache_hits=2 Qcache_inserts=5 Qcache_queries_in_cache=1 
  1. 기본값은 OFF, 크기는 1MB입니다. SET GLOBAL query_cache_type = ON으로 실행 중에 켤 수 있습니다(켠 뒤의 확인 출력은 뺐습니다).
  2. 첫 줄의 Com_select=2는 앞의 두 SELECT @@… 때문입니다. 같은 문자열을 두 번째 보내자 Qcache_hits가 1이 됐습니다. 이때도 Com_select가 올랐습니다(위 8038행).
  3. 대소문자만 다른 문장, 공백 하나 더 들어간 문장, 주석이 들어간 문장은 모두 따로 저장됐습니다(Qcache_inserts가 2, 3, 4). 열쇠가 파싱한 결과가 아니라 바이트이기 때문입니다. 주석을 떼고 비교하는 query_cache_strip_comments는 기본이 꺼짐(Sys_query_cache_strip_comments, DEFAULT(FALSE))입니다.
  4. 테이블에 UPDATE가 가자 그 테이블을 쓰는 결과 네 개가 한꺼번에 지워졌습니다(Qcache_queries_in_cache 0). 테이블 단위로 무효화하기 때문에, 쓰기가 잦은 테이블에서는 캐시가 저장과 삭제만 되풀이합니다.
  5. NOW()가 든 문장과 SQL_NO_CACHE 문장은 저장되지 않았습니다. NOW()는 파서의 문법 동작이 Lex->safe_to_cache_query=0을 적고(sql/sql_yacc.yy 11159–11165행), SQL_NO_CACHE는 파서가 safe_to_cache_query=0을 적어 저장을 막고(sql/sql_lex.cc 10434행), 캐시를 찾을 때도 파싱 전에 has_no_cache_directive()(sql_cache.cc 782행)가 글자로 알아보고 건너뜁니다.

헷갈리기 쉬운 곳: 쿼리 캐시는 잠금 하나를 모두가 씁니다

캐시를 찾고 넣고 지우는 일은 모두 Query_cache의 잠금(try_lock(), 1884행) 하나를 거칩니다. 그래서 동시 연결이 많은 서버에서는 이 잠금이 병목이 되기 쉽고, 기본값이 꺼짐인 것도 그래서라고들 설명합니다. 다만 이 글에서는 성능을 재지 않았습니다(Debug 빌드라 숫자가 의미 없습니다). 소스로 확인한 것은 "모든 연결이 같은 잠금을 쓴다"는 데까지입니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
패킷 길이 3바이트 + 순번 1바이트 + 본문. 본문 최대 0xffffff, 넘으면 쪼개고 정확히 맞으면 길이 0짜리를 덧붙인다
순번 pkt_nr do_command()의 net_new_transaction()이 명령마다 0으로. 어긋나면 ER_NET_PACKETS_OUT_OF_ORDER
max_allowed_packet 받는 버퍼를 키울 한도(net_realloc()). 넘으면 오류를 세우고 연결을 닫는다. 클라이언트에도 따로 있다
쓰기 버퍼 net_buffer_length(16KB)까지 모았다가 net_flush()·net_real_write()에서 한꺼번에
Vio 소켓·SSL·파이프를 함수 포인터로 감싼 층. vio_read()는 매크로
핸드셰이크 서버가 먼저 말한다. 프로토콜 10, 버전, 연결 ID, scramble, capabilities(CLIENT_MYSQL 비트는 꺼 둔다)
do_command() wait_timeout으로 기다리기 → 패킷 읽기 → 첫 바이트를 명령으로 → net_read_timeout으로 바꾸고 dispatch_command()
dispatch_command() 공통 머리(query_id, Questions, Command 열) → switch (command) → dispatch_end에서 답 보내기와 free_root()
COM_QUERY alloc_query → 일반 로그 → mysql_parse(). ;로 이어진 문장은 루프로 하나씩
COM_PING·COM_QUIT my_ok() 하나(Com_admin_commands) / 답 없이 연결 종료(Aborted_clients에 안 셈)
Protocol_text·Protocol_binary 같은 값을 글자로 / 형식 그대로. COM_STMT_*가 바이너리를 쓴다
Diagnostics_area 문장의 마지막 답(OK·EOF·오류)을 적는 한 칸. end_statement()가 한 번만 보낸다
early EOF 읽기 전용 SELECT는 뒷정리 전에 select_send::send_eof()가 답부터 보낸다
쿼리 캐시 파싱 전에 바이트 + DB + 플래그로 찾고, 나가는 패킷을 엿들어 저장. 기본 꺼짐, 대소문자·공백도 다른 쿼리

다음에 볼 것

COM_QUERY의 SQL 문자열은 mysql_parse() → parse_sql()로 들어갑니다. 그 문자열이 토큰으로 잘리고, Bison 문법을 지나 LEX와 SELECT_LEX, Item 나무가 되는 과정, 그리고 "You have an error in your SQL syntax … near '…'" 메시지가 만들어지는 곳을 6편: 파서 — SQL 문장이 LEX와 Item 나무가 되기까지에서 봅니다. 이 글의 쿼리 캐시가 "파서를 건너뛴다"고 할 때 건너뛰는 바로 그 부분입니다.

앞 연재와 비교하면 httpd 9편과 Tomcat 6편이 같은 자리입니다. HTTP/1.1은 글자 프로토콜이라 줄 끝(CRLF)을 찾아 요청 줄과 헤더를 읽고 한도도 "줄 길이·헤더 크기"로 재지만, MariaDB 프로토콜은 바이너리라 머리의 길이만큼 읽고 한도도 max_allowed_packet 하나입니다. 대신 HTTP의 keep-alive에 해당하는 "한 연결에서 명령을 계속 받는 루프"는 MariaDB에서는 기본입니다 — 로그인한 연결은 COM_QUIT이나 타임아웃까지 do_command()를 되풀이합니다. 결과를 버퍼에 모았다가 한꺼번에 보내는 것도 Tomcat의 응답 버퍼와 닮았습니다.

애플리케이션 쪽에서 보면 Spring 18편의 JdbcTemplate이 부르는 JDBC 드라이버가 이 글의 클라이언트 자리에 섭니다. 드라이버가 PreparedStatement를 COM_STMT_PREPARE로 보낼지, 값을 채운 SQL 문자열로 만들어 COM_QUERY로 보낼지는 드라이버와 설정에 따라 다릅니다(드라이버 소스로 확인한 것은 아닙니다). 어느 쪽인지 궁금하면 4절의 헷갈리기 쉬운 곳을 떠올려, Com_stmt_prepare만 보지 말고 Com_prepare_sql과 함께 보면 됩니다 — 프로토콜 명령으로 준비한 수는 대략 그 차이입니다(EXECUTE IMMEDIATE도 같은 Prepared_statement::prepare()를 지나 Com_stmt_prepare를 올리므로 정확히는 그 수도 빼야 합니다).

다른 글