이 글에서 다루는 것
"MariaDB 소스 파헤치기" 연재의 다섯 번째 글입니다. 4편에서는 연결이 들어오면 스레드 하나(또는 스레드 풀의 워커)가 그 연결을 맡고, 로그인을 마친 뒤 do_command()를 되풀이해 부르는 데까지 봤습니다. 이번 글은 그 do_command() 한 번의 안쪽입니다. 2편 지도로 말하면 ② "THD · 명령" 구역과, 맨 끝의 ⑨ "결과 보내기" 구역을 확대합니다.
답하려는 질문은 네 가지입니다.
- 클라이언트와 서버가 주고받는 "패킷"은 어떻게 생겼고, 16MB가 넘는 데이터나
max_allowed_packet보다 큰 요청은 어떻게 되나 - 서버는 받은 패킷을 보고 무엇을 할지 어떻게 정하나 —
COM_QUERY,COM_PING,COM_STMT_EXECUTE는 어디서 갈라지나 - 결과 행과
OK·오류는 누가 어떤 순서로 바이트로 바꿔 보내나. 준비된 문장(prepared statement)의 결과는 무엇이 다른가 - 쿼리 캐시는 왜 SQL을 파싱하기도 전에 결과를 찾을 수 있나
| 항목 | 내용 |
|---|---|
| 대상 | 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, 복제용 명령처럼 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
파란 칸이 이 글에서 읽는 곳입니다. 요청이 들어오는 입구와 결과가 나가는 출구가 둘 다 이 글의 몫입니다.
먼저 알아 둘 말
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행)
패킷의 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;
}
세 가지를 읽을 수 있습니다.
- 길이는 3바이트입니다. 그래서 한 패킷 본문은 0xffffff바이트를 넘을 수 없고, 넘으면 0xffffff짜리 조각을 이어서 보냅니다. 받는 쪽은 "길이가 정확히 0xffffff면 다음 조각이 더 있다"고 알아듣습니다.
- 순번은 패킷마다 1씩 늘어납니다(
net->pkt_nr++). 조각도 패킷 하나로 세므로 순번을 하나씩 씁니다. - 바로 소켓에 쓰지 않습니다.
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;
}
}
버퍼를 키우는 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);
}
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);
프로토콜 버전(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가 보입니다.
두 군데가 눈에 띕니다.
- capabilities 맨 아래 비트가 0입니다(
fe). 이 비트는CLIENT_MYSQL(include/mysql_com.h228행)이고, 서버가 보내는 묶음CLIENT_BASIC_FLAGS에는 들어 있지 않습니다. 클라이언트가 이 비트를 끈 채로 답하면 서버는 "MariaDB 확장을 아는 클라이언트"로 보고 예약 칸의 4바이트를 확장 capabilities로 읽습니다(sql/sql_acl.cc 16380–16386행). 인사 패킷의3d 00 00 00이 서버 쪽 확장 비트입니다. - 문자 집합이
2d(45)입니다. 이 빌드의 서버 기본 콜레이션은utf8mb4_uca1400_ai_ci이고 번호가 2304라 1바이트 칸에 들어가지 않습니다. 위 코드의handshake_cs->number > 0xFF분기가 같은 문자 집합의 주 콜레이션으로 바꿔 적습니다. 예제 첫 줄이 그 확인입니다 — 45번은utf8mb4_general_ci입니다.
이어서 로그인, 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절에서 준비된 문장과 비교합니다.
예제 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);
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);
(WITH_WSREP — Galera 클러스터용 — 블록과 오류 처리는 줄였습니다.)
- 기다리기 전에 타임아웃을
wait_timeout으로 바꿉니다.thd->get_net_wait_timeout()은 보통 세션의wait_timeout을, 트랜잭션이 열린 채 쉬고 있으면idle_transaction_timeout계열 값을 돌려줍니다(sql/sql_class.h 6491–6511행). - 이전 문장의 오류·경고를 지우고(
clear_error), 순번을 0으로 돌립니다.net_new_transaction(net)은((net)->pkt_nr=0)한 줄짜리 매크로입니다(include/mysql_com.h 637행). 예제에서 명령마다 순번이 0부터 시작한 이유입니다. my_net_read_packet(net, 1)에서 다음 명령이 올 때까지 멈춰 있습니다.SHOW PROCESSLIST에서Sleep으로 보이는 연결은 바로 이 줄에 있습니다.- 패킷이 오면 첫 바이트를 명령으로 꺼내고(
fetch_command), 타임아웃을net_read_timeout으로 되돌린 뒤dispatch_command()에 명령 번호와 첫 바이트를 뗀 나머지(packet+1,packet_length-1)를 넘깁니다.
명령 번호는 본문 첫 바이트 하나이고, 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
헷갈리기 쉬운 곳: 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 은 명령과 명령 사이의 쉬는 시간에만 걸립니다. 순서와 메시지는 위 ex/u05-commands 5번의 실제 출력입니다.
4. dispatch_command — 명령 바이트로 갈라지는 switch
dispatch_command()는 명령마다 공통으로 할 일을 하고, 큰 switch로 갈라졌다가, dispatch_end라는 이름표에서 다시 만나 뒷정리를 합니다.
회색 칸(파싱 이후, 준비된 문장)은 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) {
thd->set_command(command)가SHOW PROCESSLIST의Command열이 됩니다. 이름은sql_parse.cc133행의command_name[]배열에 있습니다("Sleep","Quit","Init DB","Query"…).query_id는 명령마다 하나씩 늘어나는 서버 전체의 번호이고,Questions상태 변수는 클라이언트가 보낸 명령 수입니다. 둘 다server_command_flags[]의 표시를 보고 건너뛰기도 합니다.COM_PING과COM_STATISTICS는CF_SKIP_QUERY_ID | CF_SKIP_QUESTIONS,COM_STMT_PREPARE·COM_STMT_CLOSE·COM_STMT_RESET은CF_SKIP_QUESTIONS입니다(sql/sql_parse.cc 485–513행).
짧은 명령들: COM_PING, COM_QUIT
case COM_PING:
status_var_increment(thd->status_var.com_other);
my_ok(thd); // Tell client we are alive
break;
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;
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);
(WITH_WSREP 블록과 performance_schema·DTrace 계측은 줄였습니다.)
alloc_query()가 패킷의 SQL 문자열을THD의 메모리로 복사합니다.general_log_write()가 일반 쿼리 로그(general_log)에 남기는 곳도 여기, 파싱하기 전입니다. 그래서 문법이 틀린 문장도 일반 로그에는 남습니다.mysql_parse()가 파싱하고 실행합니다. 안쪽은 6편부터 봅니다.- 패킷 하나에 문장이 여럿 들어 있으면(
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
위에서 아래로: 무엇을 보낼지(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);
}
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);
}
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;
}
텍스트 쪽은 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
- 준비의 답 첫 패킷
0c 00 00 01 | 00 01 00 00 00 01 00 00 00 00 00 00은send_prep_stmt()(sql/sql_prepare.cc 448–454행)가 만든 것입니다: OK 표시00, 문장 ID01 00 00 00(= 1), 열 1개, 매개변수 0개, 경고 0. 그 뒤로 열 정의와 EOF가 따라옵니다. - 실행의 답에서 행 패킷만 다릅니다. 텍스트(예제 2)는
02 00 00 04 | 01 31, 바이너리는06 00 00 04 | 00 00 01 00 00 00— 머리00, NULL 비트맵00, 그리고 4바이트 정수 1입니다. - 마지막 줄의 카운터는 예제 2와 이 예제를 합친 변화입니다. 앞뒤를 비교하면
Com_admin_commands가 1(COM_PING),Com_select가 2 늘었습니다.COM_QUERY의SELECT 1과COM_STMT_EXECUTE의SELECT 1이 둘 다mysql_execute_command()를 지나기 때문입니다.COM_QUIT으로 끝냈으니Aborted_clients는 0 그대로입니다.
문장의 최종 답: 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));
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;
(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);
예제 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()합니다.
결과 패킷은 버퍼에 쌓였다가, 버퍼가 차거나 마지막 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
두 가지를 확인할 수 있습니다.
- 열 개수, 열 정의 하나, EOF, 행 두 개, 마지막 EOF까지 72바이트가
net_real_write()한 번에 나갔습니다(len = 72). 앞의 패킷들은 모두net->buff에 쌓여 있다가, EOF를 쓴net_send_eof()의net_flush()에서 처음으로net_real_write()가 불렸습니다. - 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]);
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;
}
앞의 주석과 공백, 여는 괄호를 건너뛴 뒤 첫 세 글자가 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);
저장하는 쪽은 두 군데에 나뉘어 있습니다. 실행 직전에 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
결과가 다 나가면 dispatch_command()의 query_cache_end_of_result()(2449행)가 저장을 마무리합니다. 결과를 따로 만들지 않고 나가는 패킷을 엿들어 복사한다는 것이 이 설계의 핵심입니다. 찾았을 때 send_data_in_chunks()로 그 바이트를 그대로 다시 쓰는 것도 같은 이유입니다.
노란 칸이 캐시 메모리, 초록 칸이 찾았을 때의 지름길입니다.
기본값은 꺼짐입니다.
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,
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
- 기본값은
OFF, 크기는 1MB입니다.SET GLOBAL query_cache_type = ON으로 실행 중에 켤 수 있습니다(켠 뒤의 확인 출력은 뺐습니다). - 첫 줄의
Com_select=2는 앞의 두SELECT @@…때문입니다. 같은 문자열을 두 번째 보내자Qcache_hits가 1이 됐습니다. 이때도Com_select가 올랐습니다(위 8038행). - 대소문자만 다른 문장, 공백 하나 더 들어간 문장, 주석이 들어간 문장은 모두 따로 저장됐습니다(
Qcache_inserts가 2, 3, 4). 열쇠가 파싱한 결과가 아니라 바이트이기 때문입니다. 주석을 떼고 비교하는query_cache_strip_comments는 기본이 꺼짐(Sys_query_cache_strip_comments,DEFAULT(FALSE))입니다. - 테이블에
UPDATE가 가자 그 테이블을 쓰는 결과 네 개가 한꺼번에 지워졌습니다(Qcache_queries_in_cache0). 테이블 단위로 무효화하기 때문에, 쓰기가 잦은 테이블에서는 캐시가 저장과 삭제만 되풀이합니다. 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.cc10434행), 캐시를 찾을 때도 파싱 전에has_no_cache_directive()(sql_cache.cc782행)가 글자로 알아보고 건너뜁니다.
헷갈리기 쉬운 곳: 쿼리 캐시는 잠금 하나를 모두가 씁니다
캐시를 찾고 넣고 지우는 일은 모두 Query_cache의 잠금(try_lock(), 1884행) 하나를 거칩니다. 그래서 동시 연결이 많은 서버에서는 이 잠금이 병목이 되기 쉽고, 기본값이 꺼짐인 것도 그래서라고들 설명합니다. 다만 이 글에서는 성능을 재지 않았습니다(Debug 빌드라 숫자가 의미 없습니다). 소스로 확인한 것은 "모든 연결이 같은 잠금을 쓴다"는 데까지입니다.
이 글에서 뺀 것
- 압축 프로토콜(
--compress):net->compress와COMP_HEADER_SIZE—sql/net_serv.cc657–685행(쓰기),my_net_read_packet_reallen()의HAVE_COMPRESS쪽(읽기) - SSL로 갈아타기: 핸드셰이크 응답의
CLIENT_SSL을 보고sslaccept()—sql/sql_acl.cc16394–16422행,vio/viossl.c - 프록시 프로토콜 머리:
handle_proxy_header()—sql/net_serv.cc752행 COM_CHANGE_USER·COM_RESET_CONNECTION:sql/sql_parse.cc1737–1812행- 복제용 명령
COM_BINLOG_DUMP·COM_REGISTER_SLAVE:sql/sql_parse.cc2133행(COM_BINLOG_DUMP)·1729행(COM_REGISTER_SLAVE), 12편 - 커서와 묶음 실행
COM_STMT_FETCH·COM_STMT_BULK_EXECUTE, 준비된 문장의 재준비(reprepare):sql/sql_prepare.cc - 세션 상태 추적(
session_track_*): OK 패킷 뒤에 붙는 정보,sql/session_tracker.cc - 스레드 풀에서 그룹 커밋을 기다리며 명령을 반쯤 멈추는
DISPATCH_COMMAND_WOULDBLOCK:sql/sql_parse.cc2376–2379행, 4편 - 쿼리 캐시의 메모리 블록 관리와 무효화:
Query_cache::invalidate()·allocate_block()등,sql/sql_cache.cc - 메타데이터 캐시
MARIADB_CLIENT_CACHE_METADATA(열 개수 뒤의 1바이트):sql/protocol.cc1204–1216행
정리
| 개념 | 한 줄 요약 |
|---|---|
| 패킷 | 길이 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를 올리므로 정확히는 그 수도 빼야 합니다).