namalzi

← 글 목록

MariaDB 소스 파헤치기 9: 실행과 handler API — 중첩 루프 조인이 엔진을 부르는 법

MariaDB · 게시

옵티마이저가 고른 계획이 실제로 행을 읽어 내는 곳을 소스로 따라갑니다. JOIN::exec()에서 do_select()와 sub_select()·evaluate_join_record()의 중첩 루프로, EXPLAIN의 type마다 정해지는 읽기 함수로, 조인 버퍼로, handler의 ha_ 감싸개를 지나 ha_innobase::rnd_next()와 write_row()까지 내려갑니다. Handler_read_* 상태 변수로 호출 수를 세고, gdb 백트레이스 한 장으로 do_command부터 엔진까지를 확인하고, 엔진을 MEMORY·Aria·BLACKHOLE로 바꿔도 같은 자리를 지나는 것을 봅니다.

이 글에서 다루는 것

"MariaDB 소스 파헤치기" 연재의 아홉 번째 글입니다. 8편에서는 옵티마이저가 JOIN::optimize() 안에서 조인 순서와 테이블마다의 접근 방법(EXPLAIN의 type 열)을 고르는 것을 봤습니다. 그 결과는 아직 "계획"일 뿐입니다. 이번 글은 그 계획이 실제로 행을 읽어 내는 곳, 그리고 서버 계층이 스토리지 엔진에게 "다음 행 주세요"라고 말하는 handler API를 따라갑니다.

2편 지도로 말하면 쿼리 하나의 일생 가운데 ⑥ 실행과 ⑦ handler 경계 구역을 확대하고, ⑧ 엔진은 입구까지만 들어갑니다. 엔진 안쪽(InnoDB가 페이지와 B-트리에서 행을 꺼내는 일)은 10편부터 봅니다.

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

항목 내용
대상 MariaDB Server
버전 13.2.0 (main, alpha)
커밋 c894d97781 (2026-10-19)
주로 읽는 파일 sql/sql_select.cc(실행 부분), sql/records.cc, sql/handler.cc·handler.h, sql/sql_insert.cc, storage/innobase/handler/ha_innodb.cc

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

sql_select.cc는 3만 6천 줄이 넘고, 그 가운데 실행에 관한 부분만 해도 수천 줄입니다. 서브쿼리, 임시 테이블, GROUP BY, 윈도 함수가 모두 같은 틀 위에 얹혀 있습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

2편 지도를 줄인 위치도. ① 연결(4편), ② THD·명령(4·5편), ③ 파싱(6편), ④ 준비(7편), ⑤ 최적화(8편)가 위에서 아래로 이어지고 모두 회색이다. 이 글이 다루는 ⑥ 실행(JOIN::exec → do_select → sub_select)과 ⑦ handler 경계(ha_rnd_next · ha_index_read_map · ha_write_row)는 파란색이다. ⑥에서 갈라지는 ⑨ 결과 보내기(end_send → select_send → Protocol, 5편)는 회색, ⑦ 아래의 ⑧ 엔진(ha_innobase · ha_heap · ha_maria, 10~12편)은 노란색이고, 회색 상자 기동·플러그인(3편)이 점선으로 ⑧을 가리킨다. 파란 칸이 이 글에서 읽는 곳입니다. 노란 칸(⑧ 엔진)은 handler 경계의 반대편으로, 이 글에서는 입구만 봅니다. ⑨ 결과 보내기는 5편의 몫이지만, ⑥에서 ⑨로 넘어가는 자리(end_send() → select_send)는 5절에서 봅니다.

먼저 알아 둘 말

JOIN 은 SELECT 하나(정확히는 SELECT_LEX 하나)를 실행하기 위한 객체입니다. 준비(JOIN::prepare(), 7편) → 최적화(JOIN::optimize(), 8편) → 실행(JOIN::exec(), 이 글)의 세 단계가 모두 이 객체의 메서드입니다.

JOIN_TAB 은 조인 순서대로 늘어선 테이블 한 칸입니다(sql/sql_select.h의 typedef struct st_join_table {...} JOIN_TAB). EXPLAIN의 한 줄이 대개 JOIN_TAB 하나에 해당합니다. 옵티마이저가 고른 접근 방법, 그 테이블에 붙은 조건 조각(select_cond), 그리고 이 글의 주인공인 함수 포인터 두 개 — 첫 행을 읽는 read_first_record와, 이 칸에서 행을 하나 찾았을 때 다음에 부를 next_select — 를 가지고 있습니다.

READ_RECORD 는 "이 테이블에서 다음 행 하나를 읽어라"를 담은 작은 구조체입니다(sql/records.h). 핵심 멤버는 read_record_func라는 함수 포인터 하나이고, 풀 스캔이면 rr_sequential, 인덱스로 같은 키를 이어 읽으면 join_read_next_same이 꽂힙니다. JOIN_TAB마다 하나씩 들어 있습니다.

handler 는 2편에서 본, 테이블 하나를 연 엔진 쪽 객체입니다(sql/handler.h의 class handler). TABLE이 열릴 때 엔진이 자기 하위 클래스(ha_innobase, ha_heap, ha_maria …)의 객체를 만들어 TABLE::file에 걸어 둡니다. 서버 계층은 엔진이 무엇인지 모른 채 table->file->...로 이 객체만 부릅니다.

ha_ 감싸개. handler에는 이름이 짝을 이루는 함수가 있습니다. ha_rnd_next()와 rnd_next(), ha_write_row()와 write_row()처럼요. 앞의 것은 서버 계층이 부르는 공통 입구(handler.cc에 한 번만 구현), 뒤의 것은 엔진이 구현하는 가상 함수입니다. 6절에서 자세히 봅니다.

record[0] 은 TABLE이 가진 행 버퍼입니다. 엔진은 읽은 행을 이 버퍼에 서버 형식으로 채워 넣고, 서버 계층의 Item_field(열 하나를 가리키는 Item, 6편)는 이 버퍼에서 값을 읽습니다. 쓰기는 반대로, 서버가 이 버퍼를 채워 ha_write_row(record[0])로 넘깁니다. 엔진과 서버 계층이 주고받는 것은 결국 이 버퍼 하나입니다.

중첩 루프 조인(nested-loop join). t1과 t2를 조인할 때 "t1의 행마다 t2에서 짝을 찾는다"는 가장 단순한 방법입니다. 의사 코드로는 for r1 in t1: for r2 in t2 where 조건(r1,r2): 내보내기(r1,r2)입니다. MariaDB는 이 틀을 기본으로 쓰고, 그 위에 조인 버퍼(4절)나 해시 조인 같은 변형을 얹습니다.

1. 실행의 입구 — JOIN::exec()에서 do_select()까지

6편과 7편에서 본 대로 SELECT는 mysql_execute_command() → execute_sqlcom_select() → handle_select() → mysql_select()로 내려옵니다. mysql_select()는 JOIN을 만들어 prepare(), optimize()를 부른 뒤 마지막으로 이 줄에 닿습니다.

  exec_error= join->exec();

sql/sql_select.cc 5461행

JOIN::exec()는 ANALYZE 문장을 위한 시간 측정으로 exec_inner()를 감쌀 뿐이고(4924–4945행), 실제 일은 exec_inner()가 합니다. exec_inner()는 테이블이 없는 SELECT 1 같은 경우, 상수 조건이 거짓인 경우, 외부 엔진으로 통째로 넘기는 경우(pushdown)를 먼저 걸러 내고, 일반적인 경우에는 끝에서 이렇게 합니다.

  THD_STAGE_INFO(thd, stage_sending_data);
  DBUG_PRINT("info", ("%s", thd->proc_info));
  result->send_result_set_metadata(
                 procedure ? procedure_fields_list : *fields,
                 Protocol::SEND_NUM_ROWS | Protocol::SEND_EOF);

  error= result->view_structure_only() ? false : do_select(this, procedure);

sql/sql_select.cc 5141–5147행

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

  1. THD_STAGE_INFO(thd, stage_sending_data) — SHOW PROCESSLIST의 State 열에 보이는 "Sending data"가 여기서 정해집니다. 바로 위 exec_inner() 머리에서는 stage_executing("Executing")으로 바꿔 둡니다(4954행). 오래 걸리는 쿼리가 "Sending data"에 머물러 있다면 아래의 do_select() 안, 곧 행을 읽고 조건을 거르는 중이라는 뜻입니다 — 이름과 달리 "보내는 중"만 뜻하지 않습니다.
  2. 열 정보(메타데이터)를 행보다 먼저 보냅니다. result는 결과를 받을 객체(select_result의 하위 클래스)이고, 보통의 SELECT라면 클라이언트로 보내는 select_send입니다. 클라이언트 프로토콜은 "열 정의 패킷들 → 행 패킷들 → EOF"의 순서이므로(5편), 행을 하나도 읽기 전에 열 정의부터 내보냅니다.

do_select()는 "모든 테이블을 조인해 소켓이나 테이블로 쓴다"는 주석이 붙은 함수입니다. 모든 테이블이 상수(const)인 경우를 따로 처리하고, 일반적인 경우에는 이 몇 줄이 전부입니다.

    JOIN_TAB *join_tab= join->join_tab +
                        (join->tables_list ? join->const_tables : 0);
    if (join->outer_ref_cond && !join->outer_ref_cond->val_bool())
      error= NESTED_LOOP_NO_MORE_ROWS;
    else
      error= join->first_select(join,join_tab,0);
    if (error >= NESTED_LOOP_OK && likely(join->thd->killed != ABORT_QUERY))
      error= join->first_select(join,join_tab,1);

sql/sql_select.cc 25081–25088행

first_select는 함수 포인터이고, JOIN을 초기화할 때 first_select= sub_select;로 정해집니다(578행). join_tab은 상수 테이블을 건너뛴 첫 번째 칸입니다. 상수 테이블(EXPLAIN의 const·system)은 옵티마이저가 make_join_statistics() 안에서 이미 한 행을 읽어 두었기 때문에(5912행 등의 join_read_const_table()) 실행 단계에서는 다시 읽지 않습니다.

마지막 인자에 주목합니다. 한 번은 0, 한 번은 1로 부릅니다. 이 셋째 인자 end_of_records가 true이면 "이제 더 들어올 행이 없다"는 신호입니다. 행을 모아 두는 단계(조인 버퍼, GROUP BY)는 이 신호를 받고서야 모아 둔 것을 내보냅니다. 4절의 조인 버퍼에서 다시 나옵니다. do_select()가 끝날 때는 join->join_free()로 테이블들의 커서를 닫고, 오류가 없으면 join->result->send_eof()로 결과의 끝을 알립니다(25133–25142행).

JOIN::exec에서 엔진까지의 호출 흐름. mysql_select가 JOIN의 optimize와 exec를 부르고, exec_inner가 send_result_set_metadata로 열 정보를 먼저 보낸 뒤 do_select에서 first_select(join, tab, 0)으로 sub_select를 부른다. sub_select는 JOIN_TAB의 read_first_record를 부르고, 그것이 handler의 ha_rnd_next나 ha_index_read_map을, 그것이 ha_innobase의 rnd_next나 index_read를 부른다. 엔진은 0(행 있음)이나 HA_ERR_END_OF_FILE을 돌려준다. sub_select는 evaluate_join_record로 WHERE 조각을 검사하고 next_select로 넘긴 뒤 read_record를 끝날 때까지 반복한다. 마지막으로 first_select(join, tab, 1)로 끝났다는 신호를 보내고, do_select 끝에서 join_free와 result->send_eof를 부른다. 실행 단계의 큰 줄기입니다. 화살표 하나하나를 아래 절에서 소스로 확인합니다.

헷갈리기 쉬운 곳: 계획을 해석하는 실행기는 따로 없습니다

옵티마이저가 "실행 계획"을 만든다고 하면 계획을 해석하는 별도의 실행기가 있을 것 같지만, 이 글이 따라가는 일반적인 실행 경로에는 그런 해석기가 없습니다. 옵티마이저가 끝날 때 남기는 것은 JOIN_TAB 배열과, 각 칸에 꽂힌 함수 포인터들입니다(3절). 실행은 그 포인터를 차례로 부르는 것뿐입니다. "계획"이 곧 "어떤 함수를 어떤 순서로 부를지"입니다.

2. 중첩 루프 — sub_select()와 evaluate_join_record()

sub_select()는 칸 하나를 맡습니다. "이 칸의 테이블에서 행을 하나씩 읽고, 읽을 때마다 evaluate_join_record()에게 넘긴다"가 전부입니다. 주석까지 합치면 길지만 뼈대는 이렇습니다.

  if (rc != NESTED_LOOP_NO_MORE_ROWS)
  {
    error= (*join_tab->read_first_record)(join_tab);
    if (!error && join_tab->keep_current_rowid)
      join_tab->table->file->position(join_tab->table->record[0]);    
    rc= evaluate_join_record(join, join_tab, error);
  }

  bool skip_over= FALSE;
  READ_RECORD *info= &join_tab->read_record;

  while (rc == NESTED_LOOP_OK && join->return_tab >= join_tab)
  {
    // ... (LooseScan 반조인 최적화 생략)
    error= info->read_record();
    // ... (LooseScan 생략)
    if (join_tab->keep_current_rowid && likely(!error))
      join_tab->table->file->position(join_tab->table->record[0]);
    
    rc= evaluate_join_record(join, join_tab, error);
  }

sql/sql_select.cc 25570–25613행

첫 행은 read_first_record로, 둘째 행부터는 read_record()(곧 READ_RECORD::read_record_func)로 읽습니다. 읽기 함수의 반환값 error는 0이면 "행을 읽었다", 음수면 "더 없다", 양수면 "오류"입니다. 그 값을 판정하는 것은 evaluate_join_record()의 첫머리입니다.

  if (error > 0 || unlikely(join->thd->is_error())) // Fatal error
    DBUG_RETURN(NESTED_LOOP_ERROR);
  if (error < 0)
    DBUG_RETURN(NESTED_LOOP_NO_MORE_ROWS);
  if (unlikely(join->thd->check_killed()))       // Aborted by user
  {
    DBUG_RETURN(NESTED_LOOP_KILLED);            /* purecov: inspected */
  }

  join_tab->tracker->r_rows++;

  if (select_cond)
  {
    select_cond_result= MY_TEST(select_cond->val_bool());
    // ... (조건 계산 중 오류 확인)

sql/sql_select.cc 25663–25676행

조건을 통과하면(외부 조인과 반조인 처리를 지나서) 다음 칸으로 넘깁니다.

    if (found)
    {
      enum enum_nested_loop_state rc;
      /* A match from join_tab is found for the current partial join. */
      rc= (*join_tab->next_select)(join, join_tab+1, 0);

sql/sql_select.cc 25804–25808행

여기가 "중첩"이 생기는 곳입니다. 앞 칸들의 행이 하나씩 정해진 채로(이것을 부분 행, partial join이라고 부릅니다) next_select가 join_tab+1, 곧 다음 칸을 맡은 sub_select()를 다시 부릅니다. 다음 칸은 처음부터 자기 테이블을 읽기 시작하고, 거기서 행을 찾으면 또 그다음 칸을 부릅니다. 마지막 칸의 next_select는 sub_select가 아니라 결과를 내보내는 end_send()입니다(5절).

중첩 루프의 흐름. do_select가 first_select인 sub_select(join_tab[0] = c)를 부른다. c의 행 하나가 evaluate_join_record에서 c에 붙은 조건을 통과하면 next_select로 sub_select(join_tab[1] = o)를 부르고, 탈락하면 c의 다음 행으로 돌아간다. o는 o.customer_id = c.id로 ref 조회를 하고, o의 행이 조건을 통과하면 마지막 칸의 next_select인 end_send를 거쳐 select_send::send_data에서 Protocol로 클라이언트에 나간다. o를 다 읽으면 NO_MORE_ROWS로 c의 루프로 돌아간다. for c in customer: for o in orders where o.customer_id = c.id: send(c, o)가 소스에서는 sub_select() → evaluate_join_record() → sub_select()의 재귀 호출이 됩니다.

루프의 상태는 enum_nested_loop_state 하나로 주고받습니다(sql/sql_select.h 442–447행). 값은 NESTED_LOOP_KILLED(-2), NESTED_LOOP_ERROR(-1), NESTED_LOOP_OK(0), NESTED_LOOP_NO_MORE_ROWS(1), NESTED_LOOP_QUERY_LIMIT(3), NESTED_LOOP_CURSOR_LIMIT(4)입니다. 음수는 멈춰야 하는 경우(오류, KILL), NO_MORE_ROWS는 "이 칸은 다 읽었다", QUERY_LIMIT는 LIMIT을 다 채워 더 읽을 필요가 없다는 뜻입니다. LIMIT 10인 쿼리가 10행을 보내고 곧바로 끝나는 것은 end_send()가 QUERY_LIMIT를 돌려주고 모든 sub_select()가 그 값을 보고 루프를 빠져나오기 때문입니다(end_send()의 주석은 이것을 "Abort nicely"라고 적어 둡니다).

예제 1: 백트레이스에 보이는 재귀

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

테이블은 셋입니다. 고객 10명(customer, 기본 키 id), 주문 30건(orders, 고객마다 3건, customer_id에 보조 인덱스), 인덱스 없는 메모 4행(city_note)입니다.

-- 이 글을 위해 쓴 예제입니다 (MariaDB 의 소스가 아닙니다)
-- 고객 10명, 주문 30건(고객마다 3건씩, 같은 고객의 주문이 연달아 놓인다), 인덱스 없는 메모 테이블 4행.
CREATE DATABASE lab;
USE lab;
CREATE TABLE customer (id INT PRIMARY KEY, name VARCHAR(20), city VARCHAR(10)) ENGINE=InnoDB;
CREATE TABLE orders (id INT PRIMARY KEY, customer_id INT, amount INT, KEY (customer_id)) ENGINE=InnoDB;
CREATE TABLE city_note (city VARCHAR(10), note VARCHAR(20)) ENGINE=InnoDB;
INSERT INTO customer SELECT seq, CONCAT('cust', seq), ELT(1 + seq % 4, 'Seoul', 'Busan', 'Daegu', 'Incheon')
  FROM seq_1_to_10;
INSERT INTO orders SELECT seq, (seq + 2) DIV 3, seq * 100 FROM seq_1_to_30;
INSERT INTO city_note VALUES ('Seoul','capital'), ('Busan','port'), ('Daegu','inland'), ('Incheon','airport');
ANALYZE TABLE customer, orders, city_note PERSISTENT FOR ALL;

SELECT STRAIGHT_JOIN SUM(o.amount) FROM customer c JOIN orders o ON o.customer_id = c.id — customer를 먼저 읽고(STRAIGHT_JOIN으로 순서를 고정) orders를 customer_id 인덱스로 찾는 조인입니다. 실행 중인 mariadbd에 gdb를 붙여 break ha_innobase::rnd_next(바깥 테이블)와 break ha_innobase::index_read if key_len > 0(키 값이 있는 호출, 곧 안쪽 테이블의 조회)을 걸고, 멈출 때마다 bt를 찍었습니다. 두 번째로 멈춘 곳의 백트레이스입니다(인자 목록과 주소는 지웠습니다).

=== stop 2: index_read with a key
#0  ha_innobase::index_read at /usr/src/mariadb/storage/innobase/handler/ha_innodb.cc:9144
#1  handler::index_read_map at /usr/src/mariadb/sql/handler.h:4443
#2  handler::ha_index_read_map at /usr/src/mariadb/sql/handler.cc:4215
#3  join_read_always_key at /usr/src/mariadb/sql/sql_select.cc:26392
#4  sub_select at /usr/src/mariadb/sql/sql_select.cc:25572
#5  evaluate_join_record at /usr/src/mariadb/sql/sql_select.cc:25808
#6  sub_select at /usr/src/mariadb/sql/sql_select.cc:25575
#7  do_select at /usr/src/mariadb/sql/sql_select.cc:25086
#8  JOIN::exec_inner at /usr/src/mariadb/sql/sql_select.cc:5147
#9  JOIN::exec at /usr/src/mariadb/sql/sql_select.cc:4935
#10 mysql_select at /usr/src/mariadb/sql/sql_select.cc:5461
#11 handle_select at /usr/src/mariadb/sql/sql_select.cc:652
#12 execute_sqlcom_select at /usr/src/mariadb/sql/sql_parse.cc:6266
#13 mysql_execute_command at /usr/src/mariadb/sql/sql_parse.cc:4038
#14 mysql_parse at /usr/src/mariadb/sql/sql_parse.cc:8005
#15 dispatch_command at /usr/src/mariadb/sql/sql_parse.cc:1903
#16 do_command at /usr/src/mariadb/sql/sql_parse.cc:1437

아래에서 위로 읽으면 이 글 전체의 지도입니다. do_command(5편) → mysql_parse(6편) → mysql_execute_command(7편) → JOIN::exec → do_select의 25086행(first_select(join,join_tab,0)) → sub_select(customer 칸) → evaluate_join_record의 25808행(next_select) → sub_select(orders 칸) → 25572행 read_first_record = join_read_always_key → handler::ha_index_read_map → (handler.h의 기본 구현 index_read_map이 키 길이를 계산해) → ha_innobase::index_read. sub_select가 두 번 보이는 것이 중첩 루프의 증거입니다.

첫 번째로 멈춘 곳(바깥 customer의 풀 스캔)은 ha_innobase::rnd_next ← handler::ha_rnd_next(handler.cc 4129행) ← rr_sequential(records.cc 517행) ← READ_RECORD::read_record ← join_init_read_record(sql_select.cc 26694행) ← sub_select(25572행)이었고, 그 아래는 위와 같습니다.

3. 칸마다 다른 읽기 함수 — EXPLAIN의 type이 함수 포인터가 되는 곳

sub_select()는 테이블을 어떻게 읽는지 모릅니다. read_first_record와 read_record_func를 부를 뿐입니다. 이 두 포인터는 옵티마이저 끝자락의 make_join_readinfo()가 칸마다 꽂아 둡니다.

      tab->next_select= sub_select;		/* normal select */
    // ...
    table->status=STATUS_NO_RECORD;
    pick_table_access_method (tab);

    if (jcl)
       tab[-1].next_select=sub_select_cache;

sql/sql_select.cc 16702–16714행

next_select는 기본이 sub_select이고, 조인 버퍼를 쓰는 칸(jcl, join cache level이 0이 아님)이면 앞 칸의 next_select를 sub_select_cache로 바꿉니다(4절). 마지막 칸의 next_select는 나중에 setup_end_select_func()가 end_send 같은 끝 함수로 덮어씁니다(4389–4390행).

읽기 함수는 pick_table_access_method()가 접근 방법(tab->type)에 따라 고릅니다.

static void
pick_table_access_method(JOIN_TAB *tab)
{
  switch (tab->type) 
  {
  case JT_REF:
    tab->read_first_record= join_read_always_key;
    tab->read_record.read_record_func= join_read_next_same;
    break;
  // ...
  case JT_EQ_REF:
    tab->read_first_record= join_read_key;
    tab->read_record.read_record_func= join_no_more_records;
    break;

sql/sql_select.cc 15822–15845행

JT_ALL(풀 스캔)과 JT_RANGE(범위)는 make_join_readinfo()가 직접 read_first_record= join_init_read_record로 정하고, JT_NEXT(인덱스 전체 스캔, EXPLAIN의 index)는 join_read_first로 정합니다(16750–16800행). 정리하면 이렇습니다.

EXPLAIN의 type JOIN_TAB::type 첫 행 (read_first_record) 다음 행 (read_record_func) 부르는 handler 함수
ALL JT_ALL join_init_read_record rr_sequential ha_rnd_init → ha_rnd_next
range JT_RANGE join_init_read_record rr_quick 범위 읽기(QUICK_SELECT)
index JT_NEXT join_read_first join_read_next ha_index_first → ha_index_next
ref JT_REF join_read_always_key join_read_next_same ha_index_read_map → ha_index_next_same
eq_ref JT_EQ_REF join_read_key join_no_more_records ha_index_read_map (한 번)
const JT_CONST join_read_const join_no_more_records 옵티마이저 단계에서 이미 읽음

eq_ref의 다음 행 함수 join_no_more_records는 그냥 -1을 돌려줍니다(26461–26465행). 유일 키로 찾았으니 짝은 많아야 하나이고, 둘째 행은 읽어 볼 필요도 없다는 뜻입니다.

JOIN, JOIN_TAB, READ_RECORD, handler의 관계. JOIN은 join_tab 배열과 first_select, result를 가지고, 테이블마다 JOIN_TAB 한 칸을 가진다. JOIN_TAB은 table, select_cond, read_first_record, next_select, read_record를 가진다. READ_RECORD는 table과 read_record_func, read_record()를 가진다. JOIN_TAB은 table->file로 handler를 가리킨다. handler에는 공개된 ha_rnd_next, ha_index_read_map, ha_write_row와 순수 가상 rnd_next, protected인 index_read, private인 write_row가 있고, ha_innobase와 ha_heap이 handler를 상속해 rnd_next, index_read, write_row를 구현한다. +는 public, #은 protected, -는 private, 기울어진 rnd_next()는 순수 가상 함수입니다. 옵티마이저가 남기는 것은 이 그림의 왼쪽 위 — 함수 포인터가 꽂힌 JOIN_TAB 배열입니다.

풀 스캔: join_init_read_record → rr_sequential

join_init_read_record()는 rowid 필터가 필요하면 먼저 만들고, 정렬(filesort)이 필요하면 정렬하고, 범위 읽기를 준비한 뒤 init_read_record()를 부릅니다. 평범한 풀 스캔이면 거기서 이 갈래로 갑니다.

  else
  {
    DBUG_PRINT("info",("using rr_sequential"));
    info->read_record_func= rr_sequential;
    if (unlikely(table->file->ha_rnd_init_with_error(1)))
      DBUG_RETURN(1);

sql/records.cc 327–332행

ha_rnd_init()으로 엔진에게 "처음부터 훑을 준비"를 시키고, 다음 행 함수로 rr_sequential을 꽂습니다. join_init_read_record()는 마지막 줄에서 그 함수를 한 번 불러 첫 행을 읽습니다(return tab->read_record.read_record();, 26694행). rr_sequential은 짧습니다.

int rr_sequential(READ_RECORD *info)
{
  int tmp;
  while ((tmp= info->table->file->ha_rnd_next(info->record())))
  {
    tmp= rr_handle_error(info, tmp);
    break;
  }
  return tmp;
}

sql/records.cc 514–523행

info->record()는 table->record[0]입니다(records.h 78행). 엔진이 그 버퍼에 다음 행을 채워 주면 0, 끝이면 HA_ERR_END_OF_FILE이고, rr_handle_error()가 끝을 -1로 바꿔 돌려줍니다.

ref: join_read_always_key → join_read_next_same

ref는 앞 칸의 값으로 키를 만들어 인덱스를 찾습니다.

static int
join_read_always_key(JOIN_TAB *tab)
{
  // ... (처음 한 번 ha_index_init() 으로 인덱스를 여는 부분 생략)
  if (unlikely(cp_buffer_from_ref(tab->join->thd, table, &tab->ref)))
    return -1;
  // ...
  if ((error= table->file->ha_index_read_map(table->record[0],
                                             tab->ref.key_buff,
                                             make_prev_keypart_map(tab->ref.key_parts),
                                             HA_READ_KEY_EXACT)))
  {
    if (error != HA_ERR_KEY_NOT_FOUND && error != HA_ERR_END_OF_FILE)
      return report_error(table, error);
    return -1; /* purecov: inspected */
  }
  return 0;
}

sql/sql_select.cc 26366–26402행

cp_buffer_from_ref()가 앞 칸의 값(c.id)을 키 버퍼 ref.key_buff에 복사하고, ha_index_read_map(..., HA_READ_KEY_EXACT)가 "이 값과 같은 첫 키"로 커서를 놓습니다. 다음 행부터는 join_read_next_same이 ha_index_next_same()으로 "같은 키 값의 다음 행"을 읽다가 값이 바뀌면 HA_ERR_END_OF_FILE을 받아 멈춥니다(26468–26485행).

eq_ref: join_read_key와 한 칸짜리 캐시

eq_ref의 join_read_key()는 join_read_key2()를 부르고, 거기에는 ref에 없는 장치가 하나 있습니다.

  if (cmp_buffer_with_ref(thd, table, table_ref) ||
      (table->status & (STATUS_GARBAGE | STATUS_NO_PARENT | STATUS_NULL_ROW)))
  {
    // ...
    error=table->file->ha_index_read_map(table->record[0],
                                  table_ref->key_buff,
                                  make_prev_keypart_map(table_ref->key_parts),
                                  HA_READ_KEY_EXACT);

sql/sql_select.cc 26276–26296행

cmp_buffer_with_ref()는 이번에 찾을 키를 직전에 찾은 키와 비교해서 다를 때만 참을 돌려줍니다(29707–29726행). 같으면 엔진을 부르지 않고, record[0]에 남아 있는 직전 행을 그대로 씁니다. 함수 주석은 이것을 "one-element lookup cache"라고 부릅니다(26229–26231행). 유일 키라서 같은 키면 같은 행이 나올 것이 확실하기 때문에 가능한 지름길입니다.

예제 2: Handler_read_* 로 호출 수 세기

SHOW STATUS의 Handler_read_*는 handler의 ha_ 감싸개가 부를 때마다 하나씩 올리는 숫자입니다(6절에서 그 줄을 봅니다). 그러니 접근 방법마다 엔진을 몇 번 불렀는지를 셀 수 있습니다. 쿼리마다 EXPLAIN의 주요 열을 한 줄로 찍고, 같은 세션 안에서 FLUSH STATUS; <쿼리>; SHOW SESSION STATUS WHERE Variable_name LIKE 'Handler_read%' AND Value <> '0';을 보내 0이 아닌 값만 남겼습니다(예제 폴더 ex/u09-handler-counts, 데이터는 예제 1과 같습니다). 결과입니다(조인 버퍼 부분은 4절에서 봅니다).

--- 0. no table
SELECT 1
  EXPLAIN: table=NULL type=NULL key=NULL rows=NULL extra=No tables used

--- 1. full scan (ALL)
SELECT SUM(amount) FROM orders WHERE amount > 1000
  EXPLAIN: table=orders type=ALL key=NULL rows=30 extra=Using where
  Handler_read_rnd_next  31

--- 2. primary key (const)
SELECT amount FROM orders WHERE id = 7
  EXPLAIN: table=orders type=const key=PRIMARY rows=1 extra=
  Handler_read_key       1

--- 3. secondary key (ref)
SELECT SUM(amount) FROM orders WHERE customer_id = 4
  EXPLAIN: table=orders type=ref key=customer_id rows=3 extra=
  Handler_read_key       1
  Handler_read_next      3

--- 4. join, eq_ref
SELECT STRAIGHT_JOIN SUM(o.amount), COUNT(c.name) FROM orders o JOIN customer c ON c.id = o.customer_id
  EXPLAIN: table=o type=ALL key=NULL rows=30 extra=Using where
  EXPLAIN: table=c type=eq_ref key=PRIMARY rows=1 extra=
  Handler_read_key       10
  Handler_read_rnd_next  31

--- 5. join, ref
SELECT STRAIGHT_JOIN SUM(o.amount), COUNT(c.name) FROM customer c JOIN orders o ON o.customer_id = c.id
  EXPLAIN: table=c type=ALL key=NULL rows=10 extra=
  EXPLAIN: table=o type=ref key=customer_id rows=3 extra=
  Handler_read_key       10
  Handler_read_next      30
  Handler_read_rnd_next  11

하나씩 소스에 대어 봅니다.

헷갈리기 쉬운 곳: eq_ref 조인인데 Handler_read_key가 30이 아니라 10

4번은 orders 30행마다 customer를 기본 키로 찾는 eq_ref입니다. 30번 찾았을 것 같은데 Handler_read_key는 10입니다. 예제 데이터에서 같은 고객의 주문 3건이 연달아 놓여 있어서(customer_id = 1,1,1,2,2,2,…), 30번 가운데 20번은 위에서 본 cmp_buffer_with_ref()가 "직전과 같은 키"라고 판단해 엔진을 부르지 않았기 때문입니다. 주문이 고객 순서와 무관하게 섞여 있었다면 숫자는 30에 가까워졌을 것입니다. Handler_read_key는 "논리적으로 찾은 횟수"가 아니라 엔진을 실제로 부른 횟수입니다.

4. 조인 버퍼 — sub_select_cache()와 JOIN_CACHE

안쪽 테이블에 쓸 인덱스가 없으면 중첩 루프는 비쌉니다. 바깥 행마다 안쪽 테이블을 처음부터 끝까지 다시 읽어야 하니까요. 그래서 MariaDB는 바깥 행들을 조인 버퍼에 모았다가, 안쪽 테이블을 한 번 훑으면서 버퍼의 행 전부와 맞춰 봅니다. 이것이 EXPLAIN의 Using join buffer (flat, BNL join)입니다. BNL은 Block Nested Loop, "덩어리 단위 중첩 루프"입니다.

3절에서 본 대로 조인 버퍼를 쓰는 칸은 앞 칸의 next_select가 sub_select_cache로 바뀌어 있습니다. 그래서 앞 칸에서 행을 하나 찾으면 곧장 다음 칸을 읽는 대신 이 함수로 옵니다.

  join_tab->cache->reset_join(join);

  if (end_of_records)
  {
    rc= cache->join_records(FALSE);
    if (rc == NESTED_LOOP_OK || rc == NESTED_LOOP_NO_MORE_ROWS ||
        rc == NESTED_LOOP_QUERY_LIMIT)
      rc= sub_select(join, join_tab, end_of_records);
    DBUG_RETURN(rc);
  }
  // ...
  if (!(err= test_if_use_dynamic_range_scan(join_tab)))
  {
    if (!cache->put_record())
      DBUG_RETURN(NESTED_LOOP_OK); 
    // ... (주석: 버퍼가 찼으니 버퍼의 모든 행에 대해 짝을 찾는다)
    rc= cache->join_records(FALSE);
    DBUG_RETURN(rc);
  }

sql/sql_select.cc 25314–25342행

join_records()가 부르는 JOIN_CACHE::join_matching_records()의 중심은 이 두 겹 루프입니다.

  /* Prepare to retrieve all records of the joined table */
  if (unlikely((error= join_tab_scan->open())))
  // ...
  while (!(error= join_tab_scan->next()))   
  {
    // ...
    uchar *rec_ptr;
    /* Read each possible candidate from the buffer and look for matches */
    while ((rec_ptr= get_next_candidate_for_match()))
    {

sql/sql_join_cache.cc 2358–2388행

바깥이 안쪽 테이블의 행(join_tab_scan->next()), 안쪽이 버퍼의 후보 행입니다. 순서가 뒤집혀 있습니다. JOIN_TAB_SCAN::open()은 결국 3절의 join_init_read_record()를 부르고(3497–3503행), next()는 read_record()를 부릅니다. 엔진 쪽에서 보면 버퍼 하나에 풀 스캔 한 번입니다.

조인 버퍼의 흐름. 앞 칸들(o, c)이 만든 부분 행이 앞 칸의 next_select인 sub_select_cache(join_tab = n)로 오면 cache->put_record()가 부분 행을 조인 버퍼(join_buffer_size, 기본 256KB)에 복사한다. 버퍼가 찼거나 앞 칸이 끝나면 cache->join_records()가 JOIN_TAB_SCAN::open()으로 n을 처음부터 한 번 열고, JOIN_TAB_SCAN::next()로 n의 행을 하나씩 ha_rnd_next()로 읽어 버퍼의 부분 행 전부와 맞춰 본다. 맞는 짝은 next_select로 다음 칸이나 end_send로 간다. 옆의 빨간 상자는 버퍼가 없으면 앞 칸의 행마다 n을 처음부터 다시 읽는다는 것을 보여 준다. 노란 원통이 조인 버퍼입니다. 버퍼 크기는 join_buffer_size(기본 256KB, sys_vars.cc의 Sys_join_buffer_size), 어떤 종류의 버퍼를 쓸 수 있는지는 join_cache_level(기본 2)이 정합니다.

조인 버퍼 종류는 sql/sql_join_cache.h에 클래스로 있습니다. JOIN_CACHE를 바탕으로 JOIN_CACHE_BNL(블록 중첩 루프), JOIN_CACHE_BNLH(버퍼에 해시 표를 세운 해시 조인, JOIN_CACHE_HASHED를 거쳐 상속), JOIN_CACHE_BKA·JOIN_CACHE_BKAH(Batched Key Access — 버퍼에 모은 키로 인덱스를 몰아서 찾는 방식, BKAH는 BNLH를 상속)가 갈라져 나옵니다. 버퍼에 행을 넣는 방식(해시 쪽은 put_record()를 덮어씀), 버퍼에서 짝 후보를 고르는 방식(get_next_candidate_for_match() 등), 안쪽 테이블을 읽는 방식(BKA는 JOIN_TAB_SCAN_MRR)이 다를 뿐, join_records()와 위의 sub_select_cache() 틀은 같습니다.

예제 3: 버퍼가 있을 때와 없을 때

세 테이블 조인의 마지막 칸 city_note(인덱스 없음, 4행)가 조인 버퍼를 씁니다. 같은 쿼리를 join_cache_level=0(조인 버퍼 금지)으로도 돌렸습니다.

--- 6. join buffer (BNL)
SELECT STRAIGHT_JOIN COUNT(*) FROM orders o JOIN customer c JOIN city_note n ON n.city = c.city AND c.id = o.customer_id
  EXPLAIN: table=o type=index key=customer_id rows=30 extra=Using where; Using index
  EXPLAIN: table=c type=eq_ref key=PRIMARY rows=1 extra=
  EXPLAIN: table=n type=ALL key=NULL rows=4 extra=Using where; Using join buffer (flat, BNL join)
  Handler_read_first     1
  Handler_read_key       10
  Handler_read_next      30
  Handler_read_rnd_next  5

=== join_cache_level=0 (no join buffer)
  Handler_read_first     1
  Handler_read_key       10
  Handler_read_next      30
  Handler_read_rnd_next  150

city_note를 읽은 Handler_read_rnd_next가 버퍼가 있을 때는 5(4행 + 끝, 한 번 훑음), 없을 때는 150(앞 칸에서 온 30행 × 5)입니다. 30행이 전부 256KB 버퍼 하나에 들어갔기 때문에 풀 스캔이 한 번으로 끝났습니다.

o의 type=index(Handler_read_first 1 + Handler_read_next 30)는 3절 표의 JT_NEXT → join_read_first → join_read_next 그대로입니다. customer_id 인덱스만으로 o에서 필요한 열(customer_id)이 다 나오므로(Using index) 옵티마이저가 테이블 대신 인덱스를 훑기로 한 경우입니다. 그리고 c의 eq_ref는 여기서도 3절의 한 칸짜리 캐시 덕분에 Handler_read_key 10입니다.

5. 결과 보내기 — end_send() → select_send → Protocol

마지막 칸에서 조건을 통과한 행은 next_select인 end_send()로 갑니다(setup_end_select_func()가 고릅니다. 그룹을 지어 보내야 하면 end_send_group()이고, 임시 테이블에 모으는 단계에는 end_write() 같은 다른 끝 함수가 따로 꽂힙니다). end_send()는 HAVING을 검사하고, LIMIT의 WITH TIES를 처리한 뒤 행을 넘깁니다.

  if (join->do_send_rows)
  {
    int error;
    /* result < 0 if row was not accepted and should not be counted */
    if (unlikely((error= join->result->send_data_with_check(*fields,
                                                            join->unit,
                                                            join->send_records))))
    {
      if (error > 0)
        DBUG_RETURN(NESTED_LOOP_ERROR);
      // error < 0 => duplicate row
      join->duplicate_rows++;
    }
  }

sql/sql_select.cc 26971–26984행

그 바로 뒤에서 send_records를 세어 LIMIT에 닿으면 NESTED_LOOP_QUERY_LIMIT를 돌려줍니다(2절). send_data_with_check()는 LIMIT의 OFFSET만큼은 보내지 않고 건너뛴 뒤 send_data()를 부릅니다(sql/sql_class.cc 3371–3387행). OFFSET 1000이 1000행을 읽고 버린다는 말이 여기서 확인됩니다. 클라이언트로 보내는 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))
  {
    // ... (실패하면 만들던 행을 지우고 오류)
  }

  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());
  // ...
}

sql/sql_class.cc 3477–3496행

send_result_set_row()가 SELECT 목록의 Item마다 값을 계산해 패킷에 담고, protocol->write()가 그 패킷을 네트워크 버퍼로 씁니다(패킷의 모양과 텍스트/바이너리 프로토콜의 차이는 5편). 행은 다 모은 뒤가 아니라 찾는 즉시 하나씩 나갑니다. 중첩 루프의 가장 안쪽에서 바로 프로토콜까지 이어지는 것이 이 구조의 특징입니다.

조건을 통과한 행이 나가는 길. 마지막 칸의 next_select 인 end_send() 가 HAVING 과 LIMIT … WITH TIES 를 처리하고 send_data_with_check() 를 부르며, 이 함수는 OFFSET 만큼의 행은 보내지 않고 건너뛴 뒤 join->result->send_data() 를 부른다. join->result 는 select_result 인터페이스로, 클라이언트로 보내는 select_send, INSERT … SELECT 의 select_insert, CREATE TABLE … SELECT 의 select_create, UNION 결과를 임시 테이블에 모으는 select_unit 이 같은 자리에 들어간다. select_send 는 protocol->send_result_set_row() 로 Item 마다 값을 계산해 패킷에 담고, thd->inc_sent_row_count(1) 뒤 protocol->write() 로 네트워크 버퍼에 쓴다. end_send() 는 send_records 가 LIMIT 에 닿으면 NESTED_LOOP_QUERY_LIMIT 를 돌려 루프를 멈춘다 초록 길이 클라이언트로 가는 select_send 이고, 회색 칸은 같은 자리에 들어가는 다른 받는 쪽입니다. 클래스 관계는 sql/sql_class.h(6827·6934·6964·7145행)에서 확인했습니다.

헷갈리기 쉬운 곳: 결과를 받는 쪽이 클라이언트만은 아닙니다

join->result는 select_result 인터페이스이고 select_send는 그 구현 가운데 하나일 뿐입니다. INSERT … SELECT면 행을 테이블에 넣는 select_insert, CREATE TABLE … SELECT면 select_create, UNION의 결과를 임시 테이블에 모을 때는 select_unit 같은 하위 클래스가 같은 자리에 들어갑니다(모두 sql/sql_class.h에 선언). 그래서 같은 do_select()/end_send() 틀이 "클라이언트로 보내기"와 "다른 테이블에 쓰기"를 함께 맡습니다.

6. handler의 ha_ 감싸개 — 서버 계층이 엔진을 부르는 단 하나의 문

이제 반대편, 엔진과의 경계를 봅니다. handler 클래스 안에는 이런 주석이 붙은 private: 구역이 있습니다.

private:
  /*
    Low-level primitives for storage engines.  These should be
    overridden by the storage engine class. To call these methods, use
    the corresponding 'ha_*' method above.
  */

sql/handler.h 5428–5433행

write_row(), update_row(), delete_row(), rnd_init(), index_init(), external_lock() 같은 엔진 쪽 함수가 이 아래에 private으로 선언되어 있습니다. C++에서 private 가상 함수는 하위 클래스가 덮어쓸 수는 있지만, 바깥(서버 계층)에서는 부를 수 없습니다. 그래서 서버 계층은 공개된 ha_write_row() 같은 감싸개를 거칠 수밖에 없습니다. "가상 함수는 숨기고, 공개 함수가 앞뒤 일을 한 뒤 그것을 부른다"는 C++의 흔한 설계(Non-Virtual Interface)입니다.

모든 엔진 함수가 private인 것은 아닙니다. 읽기 쪽 index_read_map()·index_next()·index_read()는 protected(앞의 둘은 4437행, index_read()는 5573행의 protected: 아래), rnd_next()는 public인 순수 가상 함수입니다(4564–4566행). 그래도 SQL 계층의 실행 코드는 이 글에서 본 대로 모두 ha_ 쪽을 부릅니다.

헷갈리기 쉬운 곳: 엔진은 같은 함수를 public으로 선언합니다

ha_innobase는 write_row()를 public: 아래에 override로 선언합니다(ha_innodb.h 120행, 65행의 public: 아래). ha_heap도 마찬가지입니다. C++은 덮어쓴 쪽이 접근 지정을 넓히는 것을 허용합니다. 하지만 서버 계층은 언제나 handler*(TABLE::file)를 통해 부르므로, 접근 검사는 handler의 선언으로 이루어집니다. 엔진 소스만 읽으면 "그냥 공개 함수"로 보이지만, 서버 계층이 직접 부를 수 있는 길은 ha_ 감싸개뿐입니다.

ha_rnd_next()가 하는 일

풀 스캔의 행 하나마다 불리는 ha_rnd_next()의 뼈대입니다.

int handler::ha_rnd_next(uchar *buf)
{
  int result;
  DBUG_ENTER("handler::ha_rnd_next");
  DBUG_ASSERT(table_share->tmp_table != NO_TMP_TABLE ||
              m_lock_type != F_UNLCK);
  DBUG_ASSERT(inited == RND);

  // ... (debug_dbug 로 오류를 흉내 내는 시험용 코드 생략)
  do
  {
    TABLE_IO_WAIT(tracker, PSI_TABLE_FETCH_ROW, MAX_KEY, result,
      { result= rnd_next(buf); })
    if (result != HA_ERR_RECORD_DELETED)
      break;
    status_var_increment(table->in_use->status_var.ha_read_rnd_deleted_count);
  } while (!table->in_use->check_killed(1));

  if (result == HA_ERR_RECORD_DELETED)
    result= HA_ERR_ABORTED_BY_USER;
  else
  {
    // ... (성공이면 행 통계와 가상 열 계산)
    increment_statistics(&SSV::ha_read_rnd_next_count);
  }

  table->status=result ? STATUS_NOT_FOUND: 0;
  // ... (DEBUG_SYNC 생략)
  DBUG_RETURN(result);
}

sql/handler.cc 4110–4154행

감싸개가 엔진 호출 앞뒤로 하는 일은 여섯 가지입니다.

  1. 사용 규칙 검사. 임시 테이블이 아니면 잠근 테이블이어야 하고(m_lock_type != F_UNLCK), ha_rnd_init()을 먼저 불렀어야 합니다(inited == RND). DBUG_ASSERT라서 Debug 빌드에서만 검사하지만, 엔진 작성자에게는 "이 순서로 부른다"는 약속입니다.
  2. 계측. TABLE_IO_WAIT 매크로가 엔진 호출을 감쌉니다(handler.h 6031–6041행). ANALYZE 문장이나 log_slow_verbosity=engine일 때만 켜지는 테이블별 시간 측정(tracker, sql_select.cc 32085–32090행)과, performance_schema의 테이블 I/O 대기 이벤트(MYSQL_TABLE_IO_WAIT)가 여기서 기록됩니다.
  3. 엔진 호출. rnd_next(buf) — 가상 함수이므로 실제로는 ha_innobase::rnd_next()나 ha_heap::rnd_next()가 불립니다.
  4. 지워진 행 건너뛰기. 엔진이 HA_ERR_RECORD_DELETED(지워진 자리)를 돌려주면 Handler_read_rnd_deleted를 올리고 다시 읽습니다. 그 사이에도 check_killed()를 봅니다.
  5. 통계. increment_statistics(&SSV::ha_read_rnd_next_count) — 이것이 Handler_read_rnd_next입니다(mysqld.cc의 상태 변수 표가 "Handler_read_rnd_next"를 이 칸에 묶습니다, 8067행). 결과가 성공이든 끝이든 오릅니다. 예제 2에서 30행 스캔이 31로 나온 이유입니다.
  6. 상태 표시. table->status를 정해 둡니다. 3절의 join_read_key2()가 이 값을 봅니다.

5번의 increment_statistics()는 숫자 하나만 올리지 않습니다.

inline void handler::increment_statistics(ulong SSV::*offset) const
{
  status_var_increment(table->in_use->status_var.*offset);
  table->in_use->check_limit_rows_examined();
}

sql/sql_class.h 8163–8167행

SELECT … LIMIT ROWS EXAMINED n의 "읽은 행 수 한도"를 검사하는 곳이 바로 이 감싸개 안입니다. 1절의 exec_inner() 머리에서 set_limit_rows_examined()로 켜 두었던 것이 여기서 쓰입니다.

ha_rnd_next 감싸개가 하는 일. rr_sequential이나 join_read_ 함수 같은 SQL 계층은 ha_ 함수만 부른다. 1. DBUG_ASSERT로 잠근 테이블인지, inited가 RND인지 확인한다. 2. TABLE_IO_WAIT로 ANALYZE 시간 추적과 performance_schema 계측을 한다. 3. rnd_next(buf) 가상 함수를 호출해 ha_innobase, ha_heap, ha_maria 가운데 하나의 rnd_next로 간다. 4. HA_ERR_RECORD_DELETED면 Handler_read_rnd_deleted를 올리고 다시 읽는다. 5. increment_statistics로 Handler_read_rnd_next를 올리고 LIMIT ROWS EXAMINED를 검사한다. 6. table->status를 설정하고 결과 코드를 그대로 돌려준다. 노란 칸만 엔진마다 다르고, 파란 칸은 모든 엔진이 똑같이 지납니다.

인덱스로 찾는 ha_index_read_map()도 같은 모양입니다. TABLE_IO_WAIT로 index_read_map()을 부르고, Handler_read_key를 올리고, 못 찾았으면 사용자 통계용 ha_read_key_miss를 하나 더 올립니다(sql/handler.cc 4204–4231행). 엔진이 index_read_map()을 덮어쓰지 않으면 handler의 기본 구현이 calculate_key_len()으로 키 길이를 계산해 index_read()로 넘기는데(sql/handler.h 4438–4444행), 예제 1의 백트레이스 #1 handler::index_read_map at handler.h:4443이 그 자리입니다. InnoDB는 이 기본 구현을 그대로 씁니다.

엔진 쪽: ha_innobase::rnd_next()

감싸개를 지나면 엔진입니다. InnoDB의 rnd_next() 본문은 if 하나입니다.

        if (m_start_of_scan) {
                error = index_first(buf);

                if (error == HA_ERR_KEY_NOT_FOUND) {
                        error = HA_ERR_END_OF_FILE;
                }

                m_start_of_scan = false;
        } else {
                error = general_fetch(buf, ROW_SEL_NEXT, 0);
        }

storage/innobase/handler/ha_innodb.cc 9670–9680행 (InnoDB 소스는 탭 들여쓰기입니다. 여기서는 8칸 공백으로 바꿔 실었습니다.)

InnoDB에서 "테이블 풀 스캔"은 클러스터드 인덱스(기본 키 B-트리)를 처음부터 끝까지 걷는 것입니다. rnd_init()이 활성 인덱스를 클러스터드 인덱스(기본 키, 기본 키가 없으면 InnoDB가 만든 숨은 행 ID 인덱스)로 바꿔 두고(9605–9642행), 첫 rnd_next()는 index_first() → index_read()로 맨 앞에 커서를 놓고, 그다음부터는 general_fetch()로 다음 레코드를 가져옵니다. index_read()와 general_fetch()는 둘 다 결국 row_search_mvcc()를 부릅니다(9220행, 9471–9472행). 그 안에서 B-트리를 걷고(10편) 트랜잭션에 맞는 판의 행을 고르는 일(11편)이 일어납니다.

예제 1을 처음 돌렸을 때 ha_innobase::index_read에 조건 없이 중단점을 걸었더니, 기대한 안쪽 테이블의 ref 조회가 아니라 바깥 테이블의 풀 스캔에서 먼저 멈췄습니다. 백트레이스는 ha_innobase::index_read ← ha_innobase::index_first ← ha_innobase::rnd_next ← handler::ha_rnd_next …였습니다. 풀 스캔도 InnoDB 안에서는 인덱스 읽기라는 것을 그렇게 알게 되었고, 그래서 if key_len > 0 조건을 붙여(index_first()는 키 없이 index_read(buf, NULL, 0, …)를 부릅니다) 예제를 고쳤습니다.

예제 4: 엔진을 바꿔도 같은 자리

같은 테이블을 엔진만 바꿔 다섯 벌 만들고, 같은 INSERT와 같은 SELECT를 보냈습니다(예제 폴더 ex/u09-engines). BLACKHOLE은 동적 플러그인이라 INSTALL SONAME 'ha_blackhole'로 먼저 올렸습니다(3편). 테이블은 CREATE TABLE t_<엔진> (id INT PRIMARY KEY, v INT) ENGINE=<엔진>, 문장은 INSERT INTO t_<엔진> SELECT seq, seq * 10 FROM seq_1_to_5와 SELECT COUNT(*) FROM t_<엔진> WHERE v > 0입니다(WHERE를 붙여 어느 엔진에서든 행을 하나하나 읽게 했습니다). 마지막 줄은 비교를 위해 SEQUENCE 엔진(seq_1_to_5)에 SUM()을 보낸 것입니다.

=== 1. same statements, different engines
InnoDB     INSERT 5 rows:  Handler_write=5
InnoDB     SELECT      :  result=5  Handler_read_rnd_next=6
Aria       INSERT 5 rows:  Handler_write=5
Aria       SELECT      :  result=5  Handler_read_rnd_next=6
MyISAM     INSERT 5 rows:  Handler_write=5
MyISAM     SELECT      :  result=5  Handler_read_rnd_next=6
MEMORY     INSERT 5 rows:  Handler_write=5
MEMORY     SELECT      :  result=5  Handler_read_rnd_next=6
BLACKHOLE  INSERT 5 rows:  Handler_write=5
BLACKHOLE  SELECT      :  result=0  Handler_read_rnd_next=1
SEQUENCE   SELECT      :  result=15

이어서 gdb로 엔진의 rnd_next에 차례로 중단점을 걸고 bt 3(위 세 프레임만)을 찍었습니다.

=== 2. who answers handler::ha_rnd_next
#0  ha_heap::rnd_next at /usr/src/mariadb/storage/heap/ha_heap.cc:425
#1  handler::ha_rnd_next at /usr/src/mariadb/sql/handler.cc:4129
#2  rr_sequential at /usr/src/mariadb/sql/records.cc:517
#0  ha_maria::rnd_next at /usr/src/mariadb/storage/maria/ha_maria.cc:2646
#1  handler::ha_rnd_next at /usr/src/mariadb/sql/handler.cc:4129
#2  rr_sequential at /usr/src/mariadb/sql/records.cc:517
#0  ha_blackhole::rnd_next at /usr/src/mariadb/storage/blackhole/ha_blackhole.cc:138
#1  handler::ha_rnd_next at /usr/src/mariadb/sql/handler.cc:4129
#2  rr_sequential at /usr/src/mariadb/sql/records.cc:517

헷갈리기 쉬운 곳: SEQUENCE 엔진의 SUM()에는 Handler_read_rnd_next가 없습니다

마지막 줄 SELECT SUM(seq) FROM seq_1_to_5 WHERE seq > 0은 15를 돌려주지만 Handler_read_rnd_next가 아예 찍히지 않았습니다(이 예제는 Handler_write와 Handler_read_rnd_next만 골라 찍습니다). 처음에는 gdb 예제에 ha_seq::rnd_next 중단점도 넣었는데, 그 중단점에 영영 닿지 않아 gdb가 멈춘 채 예제가 끝나지 않았습니다. 원인은 SEQUENCE 테이블의 seq 열이 기본 키라는 데 있습니다. 같은 컨테이너에서 이 쿼리를 따로 EXPLAIN해 보면 type=range, key=PRIMARY, Extra=Using where; Using index이고, Handler_read%를 전부 찍으면 Handler_read_key 1, Handler_read_next 5입니다. 옵티마이저가 seq > 0을 기본 키의 범위 조건으로 보고 3절 표의 range를 골랐기 때문에, 풀 스캔용 rnd_next()는 불리지 않습니다. gdb로 ha_seq::index_read_map에 멈춰 보면 handler::ha_index_read_map ← handler::read_range_first ← handler::multi_range_read_next ← QUICK_RANGE_SELECT::get_next ← rr_quick 순서로 올라갑니다. 그래서 gdb 예제에서는 SEQUENCE를 뺐습니다.

WHERE를 떼면 또 다른 길이 열립니다. SEQUENCE 엔진은 집계를 통째로 가져가는 group_by_handler 를 구현해 두었습니다. storage/sequence/sequence.cc의 create_group_by_handler()는 WHERE와 GROUP BY가 없고(408행) SELECT 목록이 기본 키에 대한 SUM()·COUNT()뿐일 때만 ha_seq_group_by_handler를 만들어 돌려줍니다(397–450행). 그러면 do_select()는 1절에서 본 중첩 루프 대신 첫머리의 join->pushdown_query->execute(join) 갈래로 갑니다(24963–24970행). 실제로 SELECT SUM(seq) FROM seq_1_to_5를 EXPLAIN하면 Extra에 Storage engine handles GROUP BY가 보이고, 실행해도 Handler_* 상태 변수가 하나도 오르지 않습니다. 엔진이 1부터 5까지의 합을 행을 읽지 않고 계산해 버리기 때문입니다. handler API 말고도 엔진이 쿼리의 큰 덩어리를 가져가는 길(group by handler, select_lex->pushdown_select)이 있다는 것만 기억해 두면 됩니다.

잠금의 입구: ha_external_lock()

handler API에는 행 함수 말고도 "이 문장에서 이 테이블을 쓰기 시작한다/끝났다"를 알리는 함수가 있습니다. 7편의 open_and_lock_tables()는 테이블을 연 뒤 mysql_lock_tables() → lock_external()에서 테이블마다 이것을 부릅니다.

    if (unlikely((error=(*tables)->file->ha_external_lock(thd,lock_type))))

sql/lock.cc 443행

lock_type은 읽기면 F_RDLCK, 쓰기면 F_WRLCK이고, 문장이 끝나 테이블을 닫을 때(close_thread_tables() → mysql_unlock_tables()) F_UNLCK로 한 번 더 부릅니다. 이름은 "외부 잠금"이지만, handler.h의 주석대로 MyISAM이 이 자리에서 fcntl로 데이터 파일에 권고 잠금을 걸던 첫 구현에서 온 이름일 뿐이고(sql/handler.h 5542–5544행), InnoDB에게는 문장의 시작과 끝을 알려 주는 신호입니다.

        switch (lock_type) {
        case F_UNLCK:
                // ...
                if (--trx->n_mysql_tables_in_use) {
                        break;
                }

                /* If the lock count drops to zero we know that the
                current SQL statement has ended */
                // ...
                if (!not_autocommit) {
                        if (!not_started) {
                                innobase_commit(thd, TRUE);
                        }
                } else if (trx->isolation_level <= TRX_ISO_READ_COMMITTED) {
                        trx->read_view.close();
                }
                break;
        case F_WRLCK:
                /* If this is a SELECT, then it is in UPDATE TABLE ...
                or SELECT ... FOR UPDATE */
                m_prebuilt->select_lock_type = LOCK_X;
                m_prebuilt->stored_select_lock_type = LOCK_X;
                goto set_lock;

storage/innobase/handler/ha_innodb.cc 16797–16825행 (탭을 8칸 공백으로 바꿔 실었습니다.)

헷갈리기 쉬운 곳: Handler_external_lock은 늘 0입니다

SHOW STATUS에는 Handler_external_lock이라는 변수가 있어서 ha_external_lock() 호출 수를 셀 것 같습니다. 그런데 기준 커밋에서 이 숫자를 올리는 코드가 없습니다. 상태 변수 표에 ha_external_lock_count 칸이 묶여 있고(sql/mysqld.cc 8052행) STATUS_VAR에 그 칸이 선언되어 있을 뿐(sql/sql_class.h 1038행), 저장소 전체(.cc·.h·.ic)를 external_lock_count로 찾아도 이 둘 말고는 나오지 않습니다. 위에서 본 ha_external_lock() 본문에도 increment_statistics()가 없습니다. 예제 5의 마지막 줄에서, INSERT·UPDATE·DELETE를 여러 번 보낸 서버에서도 SHOW GLOBAL STATUS LIKE 'Handler_external_lock'은 0이었습니다. (다른 판에서 언제부터 그런지는 확인하지 않았습니다.)

7. 쓰기 경로 — mysql_insert()에서 ha_innobase::write_row()까지

읽기의 반대편, INSERT를 봅니다. 7편에서 본 mysql_execute_command()의 큰 switch가 SQLCOM_INSERT를 mysql_insert()로 보냅니다. mysql_insert()는 테이블을 열고 잠근 뒤(open_and_lock_tables(), 여기서 6절의 ha_external_lock(F_WRLCK)), VALUES의 행마다 값을 table->record[0]에 채우고 Write_record::write_record()를 부릅니다(sql/sql_insert.cc 1232행). write_record()는 중복 처리 방식(DUP_ERROR, ON DUPLICATE KEY UPDATE의 DUP_UPDATE, REPLACE의 DUP_REPLACE)에 따라 갈라지고, 평범한 INSERT면 single_insert()입니다.

int Write_record::single_insert(ha_rows *inserted)
{
  // ... (복제 시험용 DBUG_EXECUTE_IF 생략)
  int error= table->file->ha_write_row(table->record[0]);

sql/sql_insert.cc 2387–2396행

ha_write_row()는 읽기 감싸개와 같은 틀에 쓰기에 필요한 일이 더해져 있습니다.

int handler::ha_write_row(const uchar *buf)
{
  int error;
  DBUG_ENTER("handler::ha_write_row");
  // ... (DEBUG_SYNC, WSREP 시험용 코드 생략)
  DBUG_ASSERT(table_share->tmp_table || m_lock_type == F_WRLCK);
  DBUG_ASSERT(buf == table->record[0]);

  if (!(ha_table_flags() & HA_CHECK_UNIQUE_AFTER_WRITE) &&
      (error= ha_check_inserver_constraints(NULL, buf)))
    goto err;

  MYSQL_INSERT_ROW_START(table_share->db.str, table_share->table_name.str);
  mark_trx_read_write();
  increment_statistics(&SSV::ha_write_count);

  TABLE_IO_WAIT(tracker, PSI_TABLE_WRITE_ROW, MAX_KEY, error,
                      { error= write_row(buf); })
  // ...
  rows_stats.inserted++;
  error= binlog_log_row(0, buf,
                        Write_rows_log_event::binlog_row_logging_function);

sql/handler.cc 8642–8702행

엔진 쪽 ha_innobase::write_row()는 트랜잭션 상태를 확인하고, 자동 증가 열이면 update_auto_increment()로 값을 받고, 서버 형식과 InnoDB 형식을 오가는 "템플릿"을 준비한 뒤 InnoDB의 삽입 함수로 넘깁니다.

        vers_set_fields = table->versioned_write(VERS_TRX_ID) ?
                ROW_INS_VERSIONED : ROW_INS_NORMAL;

        /* Execute insert graph that will result in actual insert. */
        error = row_insert_for_mysql((byte*) record, m_prebuilt, vers_set_fields);

storage/innobase/handler/ha_innodb.cc 7966–7970행 (탭을 8칸 공백으로 바꿔 실었습니다.)

row_insert_for_mysql()부터는 InnoDB 안쪽입니다. 클러스터드 인덱스와 보조 인덱스에 레코드를 넣는 일은 10편, undo 기록과 잠금은 11편, redo 로그는 12편에서 봅니다.

INSERT의 쓰기 경로. mysql_insert가 open_and_lock_tables에서 lock_external을 거쳐 handler의 ha_external_lock(F_WRLCK)를 부르고, handler는 ha_innobase의 external_lock으로 문장 시작과 트랜잭션 등록을 알린다. VALUES의 행마다 mysql_insert가 값을 table->record[0]에 채우고 Write_record의 write_record를 부르고, single_insert가 ha_write_row(record[0])를 부른다. handler는 mark_trx_read_write와 Handler_write 증가를 한 뒤 ha_innobase의 write_row(buf)를 부르고, ha_innobase는 자동 증가 값과 템플릿을 준비한 뒤 row_insert_for_mysql로 삽입해 DB_SUCCESS를 받는다. handler는 binlog_log_row를 부른다. 문장이 끝나면 close_thread_tables에서 mysql_unlock_tables를 거쳐 ha_external_lock(F_UNLCK)가 불린다. 읽기와 마찬가지로 쓰기도 ha_ 감싸개 하나를 지나 엔진으로 들어갑니다.

예제 5: INSERT의 백트레이스와 DML의 Handler_* 숫자

ha_innobase::write_row에 중단점을 걸고 INSERT INTO t VALUES (1,10)을 보냈습니다(예제 폴더 ex/u09-gdb-write).

=== 1. INSERT INTO t VALUES (1,10)
#0  ha_innobase::write_row at /usr/src/mariadb/storage/innobase/handler/ha_innodb.cc:7919
#1  handler::ha_write_row at /usr/src/mariadb/sql/handler.cc:8666
#2  Write_record::single_insert at /usr/src/mariadb/sql/sql_insert.cc:2396
#3  Write_record::write_record at /usr/src/mariadb/sql/sql_insert.cc:2438
#4  mysql_insert at /usr/src/mariadb/sql/sql_insert.cc:1232
#5  mysql_execute_command at /usr/src/mariadb/sql/sql_parse.cc:4549
#6  mysql_parse at /usr/src/mariadb/sql/sql_parse.cc:8005
#7  dispatch_command at /usr/src/mariadb/sql/sql_parse.cc:1903
#8  do_command at /usr/src/mariadb/sql/sql_parse.cc:1437

SELECT와 달리 JOIN도 sub_select도 없습니다. VALUES로 행을 받는 INSERT는 테이블을 읽을 필요가 없어서 실행 단계가 훨씬 짧습니다. handler.cc 8666행은 TABLE_IO_WAIT 안의 write_row(buf) 호출입니다.

이어서 같은 세션에서 FLUSH STATUS → DML → SHOW SESSION STATUS로 0이 아닌 Handler_*를 찍었습니다. 마지막 INSERT는 일부러 이미 있는 기본 키를 넣었습니다.

=== 2. Handler_* for DML
--- INSERT INTO t VALUES (2,20),(3,30),(4,40)
  Handler_commit         1
  Handler_write          3
--- UPDATE t SET v = v + 1 WHERE id >= 3
  Handler_commit         1
  Handler_read_key       1
  Handler_read_next      2
  Handler_update         2
--- DELETE FROM t WHERE id = 2
  Handler_commit         1
  Handler_delete         1
  Handler_read_key       1
--- INSERT INTO t VALUES (1,99)
  ERROR 1062 (23000) at line 1: Duplicate entry '1' for key 'PRIMARY'
  Handler_rollback       1
  Handler_write          1
Handler_external_lock	0

이 글에서 뺀 것

정리

개념 한 줄 요약
JOIN::exec() / exec_inner() 열 정보를 먼저 보내고(send_result_set_metadata) do_select()를 부른다. "Sending data" 단계가 여기서 시작된다
do_select() 상수 테이블을 건너뛴 첫 칸에 first_select(= sub_select)를 두 번 부른다 — 한 번은 행을 위해, 한 번은 "끝" 신호(end_of_records)를 위해
sub_select() 칸 하나의 루프. read_first_record 한 번, read_record() 반복, 행마다 evaluate_join_record()
evaluate_join_record() KILL 확인, 칸에 붙은 조건(select_cond) 평가, 통과하면 next_select로 다음 칸 — 여기서 중첩이 생긴다
JOIN_TAB의 함수 포인터 pick_table_access_method()·make_join_readinfo()가 EXPLAIN의 type에 맞는 읽기 함수를 꽂는다. 계획 = 부를 함수의 목록
eq_ref의 한 칸짜리 캐시 cmp_buffer_with_ref()가 직전과 같은 키면 엔진을 부르지 않는다
조인 버퍼 앞 칸의 next_select가 sub_select_cache가 되어 부분 행을 모으고, 안쪽 테이블을 버퍼마다 한 번만 훑는다
end_send() → select_send HAVING·LIMIT을 처리하고 행을 찾는 즉시 Protocol로 보낸다
ha_ 감싸개 엔진 함수는 대부분 private/protected 가상 함수, 서버 계층은 공개 감싸개만 부른다. 사용 규칙 검사·계측·Handler_* 통계·LIMIT ROWS EXAMINED·binlog가 여기서 일어난다
Handler_read_* 엔진을 부른 횟수. 결과(성공/끝)와 무관하게 오르므로 n행 스캔은 n+1이 된다
ha_external_lock() InnoDB에게는 문장의 시작(F_RDLCK/F_WRLCK)과 끝(F_UNLCK) 신호. Handler_external_lock은 기준 커밋에서 늘 0
쓰기 경로 mysql_insert() → Write_record::write_record() → single_insert() → ha_write_row() → ha_innobase::write_row() → row_insert_for_mysql()

다음에 볼 것

다음 글 10편 InnoDB 1 — 페이지, 버퍼 풀, B-트리는 이 글이 멈춘 곳, row_search_mvcc()와 row_insert_for_mysql()의 안쪽으로 들어갑니다. ha_innobase::rnd_next()가 "클러스터드 인덱스를 걷는다"고 했는데, 그 인덱스가 16KB 페이지로 이루어진 B-트리이고 그 페이지들이 버퍼 풀에 올라와 있다는 것을, ha_innodb.cc와 row0mysql.cc의 row_prebuilt_t(서버 계층과 InnoDB 사이의 다리)부터 따라갑니다.

handler API는 앞 연재에서 본 확장 구조와 닮았습니다. httpd 6편의 모듈은 훅에 함수 포인터를 등록해 요청 처리에 끼어들고, MariaDB의 엔진은 handler의 가상 함수를 구현해 행 읽기·쓰기를 맡습니다. 둘 다 "코어는 상대가 누구인지 모른 채 정해진 입구만 부른다"는 점이 같고, 다른 점은 httpd 훅이 여러 모듈을 차례로 부르는 사슬인 반면 MariaDB는 테이블 하나에 엔진 하나가 정해져 있다는 것입니다. 애플리케이션 쪽에서 보면, Spring 18편의 JdbcTemplate이 ResultSet.next()로 한 행씩 꺼내던 그 행들이 이 글의 end_send() → select_send::send_data()가 찾는 즉시 하나씩 보낸 패킷입니다.

다른 글