이 글에서 다루는 것
"MariaDB 소스 파헤치기" 연재의 일곱 번째 글입니다. 6편에서는 SQL 문장 하나가 렉서와 Bison 문법을 지나 LEX와 Item 나무가 되는 데까지 봤습니다. 이 나무는 아직 글자를 구조로 바꾼 것일 뿐입니다. SELECT name FROM t WHERE id = 1의 t가 정말 있는 테이블인지, name이 그 테이블의 열인지, 이 사용자가 읽어도 되는지는 아무도 확인하지 않았습니다.
이번 글은 2편 지도의 ④ 준비 구역을 확대합니다. 파싱이 끝난 문장이 옵티마이저에 넘어가기 전에 서버가 하는 세 가지 일 — 권한 확인, 테이블 열기, 이름 찾기 — 입니다.
파란 칸이 이 글에서 읽는 곳, 회색 칸은 앞뒤 글에서 보는 곳입니다.
답하려는 질문은 네 가지입니다.
- 파서가 넘겨준
LEX를 받은 서버는 무엇부터 하는가 —mysql_execute_command()의 큰switch는 어떻게 생겼나 - 권한은 언제, 어디서, 몇 단계로 확인하나.
GRANT표를 문장마다 읽나 - "테이블을 연다"는 것은 무슨 일인가.
table_open_cache와table_definition_cache는 각각 무엇을 담나 ALTER TABLE은 왜 열린 트랜잭션 뒤에서 기다리고, 그 뒤에 온 평범한SELECT까지 왜 멈추나. 그리고 "Unknown column" 오류는 어디서 나나
| 항목 | 내용 |
|---|---|
| 대상 | MariaDB Server |
| 버전 | 13.2.0 (main, alpha) |
| 커밋 | c894d97781 (2026-10-19) |
| 주로 읽는 파일 | sql/sql_parse.cc, sql/sql_acl.cc, sql/sql_base.cc, sql/table_cache.cc, sql/mdl.cc, sql/sql_select.cc, sql/item.cc |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
sql_acl.cc는 1만 7천 줄, sql_base.cc도 1만 줄이 넘습니다. 이 글은 SELECT 한 문장이 지나는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
THD와 LEX. THD는 연결 하나의 모든 상태를 담은 객체이고(2편), thd->lex는 파서가 문장 하나를 읽고 남긴 결과입니다(6편). lex->sql_command에 문장의 종류(SQLCOM_SELECT, SQLCOM_ALTER_TABLE …)가 들어 있습니다.
TABLE_LIST 는 파서가 FROM 절의 테이블 하나마다 만든 칸입니다. 처음에는 데이터베이스 이름과 테이블 이름, 별칭, 그리고 "이 테이블에 어떤 메타데이터 잠금을 걸지"(mdl_request)만 들어 있습니다. 이 글의 일은 결국 이 칸에 열린 테이블(TABLE_LIST::table)과 확인된 권한(TABLE_LIST::grant)을 채우는 것입니다. 문장에 나오는 모든 테이블은 lex->query_tables에서 시작하는 next_global 사슬로 이어져 있고, mysql_execute_command()는 이것을 all_tables라고 부릅니다.
TABLE_SHARE와 TABLE. 둘 다 "테이블"이지만 다릅니다. TABLE_SHARE는 테이블 정의 — 열 목록, 인덱스, 어느 엔진인지 — 이고 테이블마다 하나입니다. TABLE은 그 정의로 실제로 연 것 — 엔진 객체(handler), 행 하나를 담을 버퍼(record[]) — 이고, 같은 테이블을 동시에 쓰는 쪽마다 하나씩 따로 있습니다. 소스의 주석이 그대로 말합니다.
/**
This structure is shared between different table objects. There is one
instance of table share per one table in the database.
*/
struct TABLE_SHARE
설정 변수 table_definition_cache가 TABLE_SHARE를, table_open_cache가 TABLE을 몇 개까지 남겨 둘지 정합니다(3절).
메타데이터 잠금(MDL, metadata lock). 행이 아니라 테이블의 정의를 지키는 잠금입니다. 누가 테이블을 쓰는 동안 다른 누가 열을 지우거나 테이블을 버리지 못하게 합니다. InnoDB의 행 잠금(11편)과는 다른, 서버 계층(sql/mdl.cc)의 잠금이고 모든 엔진에 똑같이 걸립니다. 잠금 하나를 얻으면 MDL_ticket 이라는 표를 받습니다.
Item::fix_fields() 는 Item 나무의 마디마다 부르는 "이름을 실제 대상에 잇는" 함수입니다. 파서가 만든 Item_field는 name이라는 글자만 들고 있는데, fix_fields()가 끝나면 그 글자가 어느 테이블의 어느 Field 객체인지 가리키게 됩니다. 이 일을 이름 찾기(name resolution)라고 부릅니다.
1. mysql_execute_command() — 문장 종류마다 갈라지는 큰 switch
5편의 dispatch_command()가 COM_QUERY를 받으면 mysql_parse()가 파서를 돌리고(6편), 성공하면 mysql_execute_command()를 부릅니다. 이 함수는 3540행에서 시작해 6297행에서 끝나는 2천7백 줄짜리 함수이고, 그 가운데 2천 줄(3946–5958행)이 switch (lex->sql_command) 하나입니다. 이 switch의 case SQLCOM_…: 이름표만 세어도 162개입니다(안쪽에 따로 있는 작은 switch의 이름표 2개는 뺀 수입니다).
SELECT가 가는 칸은 이렇습니다. 여러 SHOW 명령이 같은 칸을 씁니다 — SHOW TABLES도 결국 information_schema를 읽는 SELECT로 바뀌기 때문입니다.
case SQLCOM_SHOW_SLAVE_STAT:
case SQLCOM_SELECT:
{
// ... (WSREP 블록 생략)
thd->status_var.last_query_cost= 0.0;
/*
lex->exchange != NULL implies SELECT .. INTO OUTFILE and this
requires FILE_ACL access.
*/
if (lex->exchange && (res= check_global_access(thd, FILE_ACL, false)))
break;
if (all_tables)
res= check_table_access(thd, SELECT_ACL, all_tables, 0, UINT_MAX, 0);
else
res= check_access(thd, SELECT_ACL, any_db.str, NULL,NULL, 0, 0);
if (!res)
res= execute_sqlcom_select(thd, all_tables);
break;
}
순서가 보입니다. 권한부터 확인하고(check_table_access()), 통과하면 execute_sqlcom_select()가 테이블을 열고 그다음 일을 합니다.
if (!(res= open_and_lock_tables(thd, all_tables, TRUE, 0)))
{
lex->resolve_optimizer_hints();
if (lex->describe)
{
// ... (EXPLAIN 이면 계획만 만들어 찍는다 — 8편)
}
else
{
// ... (ANALYZE 문장이면 결과를 버리는 Protocol 로 바꾼다)
query_cache_store_query(thd, all_tables);
res= handle_select(thd, lex, result, 0);
handle_select()는 mysql_select()를 부르고, mysql_select()가 JOIN 객체를 만들어 JOIN::prepare() → JOIN::optimize() → JOIN::exec()를 차례로 부릅니다(sql_select.cc 5427–5461행). 이 가운데 JOIN::prepare()가 이름 찾기를 맡습니다(5절). 한 장으로 모으면 이렇습니다.
이 글의 2·3·4·5절이 이 그림의 위에서 아래로 한 덩어리씩입니다.
다른 문장은 Sql_cmd 객체로 간다
모든 문장이 switch 안에 코드를 두지는 않습니다. ALTER TABLE, GRANT, CREATE TABLE 같은 문장은 파서가 Sql_cmd를 상속한 객체(Sql_cmd_alter_table 등, 6편)를 lex->m_sql_cmd에 만들어 두고, switch는 그 객체의 가상 함수 하나만 부릅니다.
case SQLCOM_GRANT:
if (thd->variables.option_bits & OPTION_IF_EXISTS)
lex->create_info.set(DDL_options_st::OPT_IF_EXISTS);
DBUG_ASSERT(lex->m_sql_cmd != NULL);
res= lex->m_sql_cmd->execute(thd);
거대한 switch를 문장별 클래스로 조금씩 옮겨 가는 중이라고 보면 됩니다. 어느 길로 가든 이 글의 세 단계(권한, 테이블 열기, 이름 찾기)는 똑같이 지납니다. 4절의 예제에서 ALTER TABLE이 바로 이 5947행을 지나 Sql_cmd_alter_table::execute()로 들어가는 것을 백트레이스로 봅니다.
2. 권한 확인 — 표는 기동할 때 한 번 읽는다
권한 표는 메모리에 있다
GRANT SELECT ON lab.t TO u1을 하면 mysql 데이터베이스의 권한 표에 행이 생깁니다. 전역 권한과 계정은 mysql.global_priv, DB 권한은 mysql.db, 테이블 권한은 mysql.tables_priv, 열 권한은 mysql.columns_priv입니다. 하지만 서버는 문장마다 이 표를 읽지 않습니다. 기동할 때 mysqld_main()이 두 번 읽어 메모리에 올려 둡니다(3편).
if (mysql_rm_tmp_tables() || acl_init(opt_noacl) ||
my_tz_init((THD *)0, default_tz_name, opt_bootstrap))
unireg_abort(1);
if (!opt_noacl)
(void) grant_init();
acl_init()은 acl_reload()를 부르고, acl_reload()는 계정·DB 쪽 표를 열어 acl_load()로 배열(acl_users, acl_dbs …)에 옮깁니다.
const uint tables_to_open= Table_host | Table_user | Table_db |
Table_proxies_priv | Table_roles_mapping;
if ((result= tables.open_and_lock(thd, tables_to_open, TL_READ)))
이름이 Table_user인데 실제로 여는 것은 global_priv입니다. 표 이름 배열의 마지막 칸이 "global_priv"이고(sql_acl.cc 825–834행), 옛 이름의 mysql.user는 이제 global_priv를 보여 주는 뷰입니다(scripts/mariadb_system_tables.sql 51행). grant_init() → grant_reload()는 같은 방식으로 tables_priv·columns_priv·procs_priv를 열어 grant_load()로 해시 표(column_priv_hash …)에 옮깁니다(sql_acl.cc 10015행).
FLUSH PRIVILEGES가 하는 일이 바로 이 두 함수를 다시 부르는 것입니다(sql_reload.cc 99–100행). GRANT·REVOKE 문장은 표에 쓰면서 메모리도 함께 고치므로(mysql_table_grant()의 column_priv_insert(), 9021행) FLUSH 없이 바로 먹습니다. 표를 DELETE나 UPDATE로 직접 고치면 메모리는 모릅니다.
노란 상자가 메모리에 올라간 권한입니다. 문장을 실행할 때는 이것만 봅니다.
세 층: 전역·DB → 테이블 → 열
check_table_access()는 문장의 테이블마다 check_access()를 부르고, 끝으로 check_grant()를 한 번 부릅니다.
if (check_access(thd, want_access, table_ref->get_db_name().str,
&table_ref->grant.privilege,
&table_ref->grant.m_internal,
0, no_errors))
return 1;
}
return check_grant(thd,requirements,org_tables,
any_combination_of_privileges_will_do,
number, no_errors);
check_access()(sql_parse.cc6676행)는 로그인할 때THD의Security_context에 들어온 전역 권한(master_access)과, 그 DB에 대한 DB 권한을 봅니다. 여기서 모두 채워지면 끝입니다. 모자라도 "테이블이나 열 권한으로 채워질 수 있는" 종류(SELECT같은 것)면 일단 통과시키고 아래층에 맡깁니다. 이때 스레드 상태가SHOW PROCESSLIST에서 보던checking permissions로 바뀝니다(6715–6716행).check_grant()(sql_acl.cc10114행)는column_priv_hash에서 그 테이블의 항목을 찾아 테이블 권한을 더합니다. 그래도 모자라면 남은 권한을TABLE_LIST::grant.want_privilege에 적어 둡니다 — "열 단위로 다시 봐야 함"이라는 표시입니다.- 열 권한은 아직 어느 열을 쓰는지 모르므로 여기서 볼 수 없습니다. 5절의 이름 찾기가 열 이름을
Field에 이을 때check_column_grant_in_table_ref()가 확인합니다(sql_acl.cc10478행. 이름 찾기 쪽에서 부르는 곳은find_field_in_table_ref()안,sql_base.cc7087행).
이 세 층이 GRANT의 세 모양(ON *.*, ON db.*, ON db.t)과 열 목록 GRANT SELECT (id, name) ON …에 그대로 대응합니다.
예제 1: 열 권한, 그리고 메모리에 올라간 권한
이 예제와 이 글의 다른 예제는 기준 커밋(c894d97781)을 그대로 Debug 빌드한 MariaDB 13.2.0(13.2.0-MariaDB-debug)으로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. 예제 폴더는 ex/u07-privileges입니다. id와 name 두 열만 읽을 수 있는 사용자를 만듭니다.
# 이 글을 위해 쓴 예제입니다 (MariaDB 의 소스가 아닙니다)
# ... (lab-init, lab-start 로 서버를 띄운 뒤)
mariadb -e "CREATE DATABASE lab;
CREATE TABLE lab.t (id INT PRIMARY KEY, name VARCHAR(20), secret VARCHAR(20));
CREATE TABLE lab.t2 (id INT PRIMARY KEY);
INSERT INTO lab.t VALUES (1,'one','s1'),(2,'two','s2');
CREATE USER u1@localhost IDENTIFIED BY 'pw';
GRANT SELECT (id, name) ON lab.t TO u1@localhost;"
# ... (이어서 u1 로 문장을 보내고 권한 표를 고치는 부분 — 아래 출력)
그리고 u1로 네 문장을 보냅니다(mariadb 클라이언트가 오류 앞에 다시 찍어 주는 문장은 지웠습니다).
--- 1. column privilege only: id, name
id name
1 one
2 two
--- 2. a column without privilege
ERROR 1143 (42000) at line 1: SELECT command denied to user 'u1'@'localhost' for column 'secret' in table 't'
--- 3. SELECT * also needs every column
ERROR 1142 (42000) at line 1: SELECT command denied to user 'u1'@'localhost' for table `lab`.`t`
--- 4. a table without privilege
ERROR 1142 (42000) at line 1: SELECT command denied to user 'u1'@'localhost' for table `lab`.`t2`
2번은 열 층에서 막혀 1143(열 이름이 나옴), 4번은 테이블 층에서 막혀 1142입니다. 4번에서 t2에는 열 권한조차 없으므로 check_grant()가 "권한이 아무것도 없다"며 바로 거절합니다.
이어서 root로 권한 표의 행을 직접 지우고, FLUSH PRIVILEGES 전과 후에 같은 문장을 보냅니다.
--- 5. delete the grant row directly (no FLUSH PRIVILEGES)
rows_in_columns_priv
0
id name
1 one
2 two
--- 6. after FLUSH PRIVILEGES
ERROR 1044 (42000): Access denied for user 'u1'@'localhost' to database 'lab'
표에는 행이 0개인데 u1은 여전히 읽습니다. 서버가 보는 것은 메모리의 column_priv_hash이기 때문입니다. FLUSH PRIVILEGES로 다시 읽은 뒤에야 권한이 사라지고, 이번에는 접속할 때 기본 DB로 준 lab에 들어가는 것부터 막혀 1044가 납니다.
헷갈리기 쉬운 곳: SELECT *는 1143이 아니라 1142입니다
예제 3번의 SELECT * FROM t는 secret 열 때문에 막혔는데, 오류는 열 이름을 말하지 않고 테이블 거부(1142)로 나왔습니다. *를 펼칠 때 권한을 한꺼번에 보는 check_grant_all_columns()가 일부러 그렇게 합니다.
/*
Do not give an error message listing a column name unless the user has
privilege to see all columns.
*/
if (using_column_privileges)
my_error(ER_TABLEACCESS_DENIED_ERROR, MYF(0),
command, sctx->priv_user,
sctx->host_or_ip, db_name, table_name);
else
my_error(ER_COLUMNACCESS_DENIED_ERROR, MYF(0),
주석대로, 모든 열을 볼 권한이 없는 사용자에게는 오류 메시지에 열 이름을 싣지 않습니다. 열 권한만 가진 사용자에게 "secret이라는 열이 있다"고 알려 주지 않는 셈입니다. 반대로 사용자가 열 이름을 직접 적은 2번은 이름 찾기 중에 check_grant_column()이 그 이름을 넣어 1143을 냅니다(sql_acl.cc 10448행).
헷갈리기 쉬운 곳: 기준 커밋에는 DENY가 들어와 있습니다
check_access()와 check_grant()를 읽다 보면 access_t, denied_privs(), allow_bits() 같은 이름이 나옵니다. mysql.global_priv의 JSON에 "거부 목록(denies)"을 두는 기능이 이 판에 들어와 있어서(sql_acl.cc 1365행 근처의 주석), 권한이 "허용 비트"와 "거부 비트" 두 벌로 다뤄집니다. 기준 커밋의 git 기록을 보면 이 이름들은 2026년 6월의 커밋 934108aab5c("MDEV-14443 DENY statement")로 들어왔습니다. 최근 판에 들어온 것이라, 흔히 쓰는 LTS 판의 소스와는 이 부분이 다를 수 있습니다(LTS 판의 소스와 대조해 보지는 않았습니다). 이 글은 거부 쪽 길을 따라가지 않았습니다.
3. 테이블 열기 — TABLE_SHARE와 TABLE, 두 겹의 캐시
권한을 통과하면 open_and_lock_tables()가 테이블을 엽니다.
if (open_tables(thd, options, &tables, &counter, flags, prelocking_strategy))
goto err;
// ...
if (lock_tables(thd, tables, counter, flags))
goto err;
open_tables()는 all_tables 사슬을 돌며 테이블마다 open_and_process_table() → open_table()을 부르고, 모두 열리면 lock_tables()가 엔진마다 handler::external_lock()을 불러 "이 문장이 이 테이블을 씁니다"라고 알립니다(엔진이 문장의 시작과 끝을 아는 자리입니다 — 9편·11편). open_tables()에 들어서면 스레드 상태가 Opening tables가 됩니다(4892행).
open_table() 한 번이 하는 일
open_table()은 1960행에서 시작해 400줄이 넘지만, 평범한 경우의 줄기는 세 줄로 줄어듭니다. 메타데이터 잠금을 얻고, 정의를 얻고, TABLE을 얻습니다.
if (! (flags & MYSQL_OPEN_HAS_MDL_LOCK))
{
DEBUG_SYNC(thd, "before_open_table_get_mdl_lock");
if (open_table_get_mdl_lock(thd, ot_ctx, &table_list->mdl_request,
flags, &mdl_ticket) ||
mdl_ticket == NULL)
{
DEBUG_SYNC(thd, "before_open_table_wait_refresh");
DBUG_RETURN(TRUE);
}
// ...
retry_share:
share= tdc_acquire_share(thd, table_list, gts_flags, &table);
메타데이터 잠금은 4절에서 따로 봅니다. 여기서는 tdc_acquire_share()부터 봅니다. 이름의 tdc는 table definition cache, 곧 TABLE_SHARE를 담는 캐시입니다. 이 함수는 TABLE_SHARE를 돌려주면서, 쉬고 있는 TABLE이 있으면 그것도 &table에 꺼내 줍니다.
정의 하나(TABLE_SHARE)에 인스턴스 여럿(TABLE). 엔진 객체 handler는 TABLE마다 하나입니다.
if (out_table && (flags & GTS_TABLE))
{
if ((*out_table= tc_acquire_table(thd, element)))
{
// ...
status_var_increment(thd->status_var.table_open_cache_hits);
DBUG_RETURN(element->share);
}
status_var_increment(thd->status_var.table_open_cache_misses);
}
캐시에 정의가 아예 없으면(처음 보는 테이블이면) 870행의 while 안으로 들어가 alloc_table_share()로 빈 TABLE_SHARE를 만들고 open_table_def()로 .frm 파일을 읽어 채웁니다(890–897행). 이때 Opened_table_definitions가 하나 늘어납니다(table.cc 3694행).
open_table()로 돌아와서, 쉬고 있는 TABLE을 못 받았으면 새로 만듭니다.
else
{
enum open_frm_error error;
/* make a new table */
if (!(table=(TABLE*) my_malloc(key_memory_TABLE, sizeof(*table),
MYF(MY_WME))))
goto err_lock;
error= open_table_from_share(thd, share, &table_list->alias,
HA_OPEN_KEYFILE | HA_TRY_READ_ONLY,
EXTRA_RECORD,
thd->open_options, table, FALSE,
IF_PARTITIONING(table_list->partition_names,0));
// ...
/* Add table to the share's used tables list. */
tc_add_table(thd, table);
from_share= true;
}
open_table_from_share()(sql/table.cc)가 TABLE_SHARE의 열 정의를 복사해 Field 객체들을 만들고, 엔진의 handlerton(엔진마다 하나씩 있는, 엔진이 서버에 내놓는 함수 표)에게 handler 객체를 받아(get_new_handler(), 2편) ha_open()으로 엽니다. InnoDB라면 여기서 ha_innobase::open()이 불립니다. 이 일이 비싸기 때문에 TABLE을 캐시에 남겨 둡니다. 새로 만들 때마다 Opened_tables가 하나 늘어납니다(table.cc 4896행).
노란 상자가 비싼 일(파일을 읽거나 엔진 객체를 여는 일), 초록 상자가 캐시에서 바로 꺼낸 경우입니다.
문장이 끝나면 TABLE은 돌려준다
연 TABLE은 그 연결이 영원히 갖는 것이 아닙니다. mysql_execute_command()의 끝에서 문장마다 돌려줍니다.
/* Free tables. Set stage 'closing tables' */
close_thread_tables_for_query(thd);
돌려받은 TABLE은 tc_release_table()로 그 정의의 free_tables 목록에 들어가 다음 사람을 기다립니다. 그래서 "열린 테이블 캐시"는 연결마다가 아니라 서버 전체에 하나이고, table_open_cache는 "동시에 열려 있을 수 있는 TABLE 객체의 수"에 가깝습니다. 같은 테이블을 열 연결이 동시에 읽으면 같은 TABLE_SHARE에 TABLE이 열 개 생깁니다.
예제 2: Opened_tables와 Table_open_cache_hits
ex/u07-table-cache는 FLUSH TABLES로 캐시를 비운 뒤 한 연결에서 같은 SELECT를 두 번 하고, 그 사이사이 세션 상태 변수를 찍습니다. 먼저 테이블 파일과 캐시 크기입니다.
--- 0. files of table t
db.opt
t.frm
t.ibd
--- 0. cache sizes
@@table_definition_cache @@table_open_cache @@table_open_cache_instances @@use_stat_tables
400 2000 8 PREFERABLY_FOR_QUERIES
t.frm이 서버 계층의 정의 파일(TABLE_SHARE의 재료), t.ibd가 InnoDB의 데이터 파일입니다. 기본값 400과 2000은 sql_const.h의 TABLE_DEF_CACHE_DEFAULT·TABLE_OPEN_CACHE_DEFAULT와 같습니다(145–147행). 상태 변수는 이렇게 변했습니다(세 번 찍은 SHOW SESSION STATUS 표를 하나로 모았습니다).
| 시점 | Opened_table_definitions |
Opened_tables |
Table_open_cache_hits |
Table_open_cache_misses |
|---|---|---|---|---|
1. FLUSH TABLES 직후 |
0 | 0 | 0 | 0 |
2. 첫 SELECT COUNT(*) FROM lab.t 뒤 |
4 | 4 | 0 | 4 |
3. 같은 SELECT를 한 번 더 한 뒤 |
4 | 4 | 1 | 4 |
두 번째 SELECT는 정의도 TABLE도 새로 만들지 않고 캐시에서 꺼냈습니다(hits 1). 그런데 첫 SELECT는 테이블 하나를 읽었는데 왜 4일까요. 그 사이에 찍은 SHOW OPEN TABLES가 답입니다.
+----------+--------------+--------+-------------+
Database | Table | In_use | Name_locked |
+----------+--------------+--------+-------------+
mysql | table_stats | 0 | 0 |
mysql | index_stats | 0 | 0 |
mysql | column_stats | 0 | 0 |
lab | t | 0 | 0 |
+----------+--------------+--------+-------------+
open_and_lock_tables()는 테이블을 다 연 뒤 read_statistics_for_tables_if_needed()를 부릅니다(6060–6062행). use_stat_tables가 기본값 PREFERABLY_FOR_QUERIES이므로 옵티마이저용 통계 표 세 개(mysql.table_stats·column_stats·index_stats)까지 열어 읽은 것입니다. 통계는 8편의 이야기입니다. 두 번째 SELECT 뒤에는 이 표들이 다시 열리지 않았습니다(hits가 1만 늘었습니다).
마지막으로 다른 연결이 SELECT COUNT(*), SLEEP(3) FROM lab.t로 t를 붙잡고 있는 동안, 같은 테이블을 두 번(t와 t AS t_again) 쓰는 문장을 보냅니다(가운데 COUNT(*) 결과는 ...로 줄였습니다).
--- 4. while another session is reading t
+----------+-------+--------+-------------+
Database | Table | In_use | Name_locked |
+----------+-------+--------+-------------+
lab | t | 1 | 0 |
+----------+-------+--------+-------------+
...
+------------------------+-------+
Variable_name | Value |
+------------------------+-------+
Open_table_definitions | 4 |
Open_tables | 6 |
+------------------------+-------+
In_use 1이 다른 연결이 쥔 TABLE입니다. 정의는 여전히 4개인데 열린 TABLE은 6개 — 통계 표 3개에 t의 TABLE이 3개(다른 연결의 것 하나, 이 문장의 t와 t_again 둘)입니다. 정의 하나에 인스턴스 여럿이 숫자로 보입니다.
헷갈리기 쉬운 곳: 테이블 캐시는 여러 조각으로 나뉘어 있습니다
table_open_cache_instances(기본 8, sys_vars.cc 4486–4489행)는 캐시를 몇 조각으로 나눌지입니다. tc_acquire_table()은 thd->thread_id % n_instances로 조각을 골라 그 조각의 뮤텍스만 잡습니다(table_cache.cc 398–415행). 연결이 많을 때 한 뮤텍스에 몰리지 않게 하려는 것입니다. 그래서 "쉬고 있는 TABLE"도 조각마다 따로 있고(TDC_element::free_tables[]), 다른 조각에서 쉬고 있는 TABLE은 못 가져다 쓸 수 있습니다. 처음부터 8개를 다 쓰지는 않고 경합이 보이면 늘립니다(lock_and_check_contention(), tc_active_instances).
헷갈리기 쉬운 곳: "Table doesn't exist"는 이름 찾기보다 먼저 납니다
없는 테이블을 쓰면 1146이 납니다. 이 오류는 5절의 이름 찾기가 아니라 여기, tdc_acquire_share()가 .frm(과 엔진의 테이블 찾기)을 실패할 때 납니다. 예제 4에서 1054와 나란히 찍어 둡니다. 테이블을 다 연 뒤에야 열 이름을 볼 수 있으니 순서가 그렇습니다.
4. 메타데이터 잠금 — 왜 ALTER TABLE은 기다리나
잠금의 종류와 길이
파서는 FROM의 테이블마다 TABLE_LIST::mdl_request에 "어떤 MDL을 얼마 동안 걸지"를 적어 둡니다(sql_parse.cc 8309–8310행). 종류(enum_mdl_type, sql/mdl.h 54–187행)는 꽤 많지만 이 글에서는 넷만 알면 됩니다.
| 종류 | 약자 | 누가 거나 |
|---|---|---|
MDL_SHARED_READ |
SR | SELECT가 읽는 테이블 |
MDL_SHARED_WRITE |
SW | INSERT·UPDATE·DELETE, SELECT … FOR UPDATE |
MDL_SHARED_UPGRADABLE |
SU | ALTER TABLE이 먼저 거는 것. 나중에 X로 올릴 수 있다 |
MDL_EXCLUSIVE |
X | 정의를 실제로 바꾸는 순간. 다른 모든 것과 맞지 않는다 |
길이(enum_mdl_duration)는 셋입니다 — 문장 끝에 푸는 MDL_STATEMENT, 트랜잭션 끝에 푸는 MDL_TRANSACTION, 직접 풀어야 하는 MDL_EXPLICIT(mdl.h 260–277행). 테이블 MDL은 MDL_TRANSACTION으로 요청합니다. 실제로 언제 푸는지는 mysql_execute_command()의 끝이 정합니다.
else if (! thd->in_sub_stmt_ps_unsafe() &&
! thd->in_active_multi_stmt_transaction())
{
/*
- If inside a multi-statement transaction,
defer the release of metadata locks until the current
transaction is either committed or rolled back. This prevents
other statements from modifying the table for the entire
duration of this transaction. This provides commit ordering
and guarantees serializability across multiple transactions.
- If in autocommit mode, or outside a transactional context,
automatically release metadata locks of the current statement.
*/
thd->release_transactional_locks();
}
else if (! thd->in_sub_stmt_ps_unsafe())
{
thd->mdl_context.release_statement_locks();
}
autocommit 문장이면 문장 끝에 바로 풉니다. 하지만 BEGIN 뒤의 문장이면 트랜잭션이 끝날 때까지 잡아 둡니다. 3절에서 본 TABLE은 문장 끝에 돌려주는데 MDL은 남는 것 — 이것이 이 절의 모든 이야기의 출발점입니다. 주석이 말하는 이유는 "트랜잭션 중간에 누가 테이블 정의를 바꾸지 못하게"입니다. 트랜잭션 안에서 두 번 읽은 테이블의 열이 그사이 바뀌어 있으면 곤란하기 때문입니다.
TABLE 은 문장마다 돌려주고, MDL 은 트랜잭션이 끝날 때 풉니다. 순서는 mysql_execute_command() 끝의 close_thread_tables_for_query()(6045행)와 위 인용(6084–6102행)에서 확인했습니다.
맞는 잠금, 안 맞는 잠금
두 잠금이 같이 있어도 되는지는 mdl.cc의 표 두 장이 정합니다. 첫 장은 이미 받은 잠금과의 관계입니다.
Request | Granted requests for lock |
type | S SH SR SW SU SRO SNW SNRW X |
----------+------------------------------------+
S | + + + + + + + + - |
SH | + + + + + + + + - |
SR | + + + + + + + - - |
SW | + + + + + - - - - |
SU | + + + + - + - - - |
SRO | + + + - + + + - - |
SNW | + + + - - + - - - |
SNRW | + + - - - - - - - |
X | - - - - - - - - - |
SU 줄의 SR 칸이 +입니다. 누가 읽고 있어도 ALTER TABLE은 일단 SU를 받고 들어갈 수 있습니다. 하지만 X 줄은 전부 -입니다. 두 번째 장은 기다리는 잠금과의 관계 — 곧 줄서기 규칙입니다.
Request | Pending requests for lock |
type | S SH SR SW SU SRO SNW SNRW X |
----------+-----------------------------------+
S | + + + + + + + + - |
SH | + + + + + + + + + |
SR | + + + + + + + - - |
SR 줄의 X 칸이 -입니다. X를 기다리는 사람이 있으면 새 SR은 받은 잠금과 맞더라도 그 뒤에 줄을 섭니다. X를 원하는 쪽이 읽는 쪽에 영영 밀리지 않게 하려는 규칙입니다(SH 줄은 information_schema처럼 정의만 잠깐 보는 쪽이라 이 규칙에서 빠집니다).
MDL_context::acquire_lock()
잠금을 얻는 함수는 길지만 뼈대는 이렇습니다.
bool
MDL_context::acquire_lock(MDL_request *mdl_request, double lock_wait_timeout)
{
// ...
if (try_acquire_lock_impl(mdl_request, lock_wait_timeout ? &ticket : nullptr))
{
DBUG_PRINT("mdl", ("OOM: %s", mdl_lock_name));
DBUG_RETURN(TRUE);
}
if (mdl_request->ticket)
{
// ...
DBUG_RETURN(FALSE);
}
// ...
will_wait_for(ticket);
// ...
find_deadlock();
// ...
for (;;)
{
// ...
wait_status= m_wait.timed_wait(m_owner, &abs_shortwait, FALSE,
mdl_request->key.get_wait_state_name());
// ...
}
// ...
if (wait_status != MDL_wait::GRANTED)
{
// ...
case MDL_wait::VICTIM:
// ...
my_error(ER_LOCK_DEADLOCK, MYF(0));
break;
case MDL_wait::TIMEOUT:
DBUG_PRINT("mdl", ("Timeout: %s", ticket_msg));
my_error(ER_LOCK_WAIT_TIMEOUT, MYF(0));
break;
// ...
}
// ...
m_tickets[mdl_request->duration].push_front(ticket);
try_acquire_lock_impl()이 기다리지 않고 받아 보려 합니다. 이미 같은 잠금이나 더 센 잠금을 쥐고 있으면(find_ticket()) 그것으로 끝, 표 두 장에 비춰 맞으면 그 자리에서MDL_ticket을 받습니다.- 못 받았으면 기다립니다. 먼저
find_deadlock()으로 "내가 기다리는 상대가 다시 나를 기다리는가"를 MDL끼리의 대기 그래프에서 찾습니다. 그다음 1초씩 끊어timed_wait()합니다. 1초마다 깨어나 클라이언트가 아직 붙어 있는지 보고 잠금을 쥔 쪽에 알림을 다시 보냅니다. 이때 스레드 상태가Waiting for table metadata lock입니다 — 네임스페이스마다 정해진 문구가mdl.cc101–112행의 배열에 있습니다. lock_wait_timeout초(기본 86400초, 곧 하루 —sys_vars.cc1613–1617행)가 지나면 1205Lock wait timeout exceeded, 교착의 희생자로 뽑히면 1213(ER_LOCK_DEADLOCK)입니다.- 받으면 표를 길이별 목록
m_tickets[duration]에 넣습니다. 나중에release_transactional_locks()가 이 목록을 비웁니다.
잠금 하나(테이블 하나)마다 MDL_lock 객체가 하나 있고, 그 안에 받은 표의 목록 m_granted와 기다리는 표의 목록 m_waiting이 있습니다(mdl.cc 991–994행). 표 두 장은 이 두 목록에 대해 검사하는 것입니다.
예제 3: ALTER TABLE 뒤에 줄 선 SELECT
ex/u07-mdl-wait는 세 연결을 1초 간격으로 띄웁니다.
- A:
BEGIN; SELECT COUNT(*) FROM lab.t; SELECT SLEEP(20); COMMIT;— 한 번 읽고 20초 동안 커밋하지 않습니다. - B:
ALTER TABLE lab.t ADD COLUMN c INT - C:
SELECT COUNT(*) FROM lab.t— 그냥 읽기만 합니다.
그리고 1초 뒤 상태를 찍습니다. 잠금을 보려고 INSTALL SONAME 'metadata_lock_info'로 플러그인을 올려 두었습니다(시각은 실행마다 다릅니다. 연결 ID 5·6·7은 예제를 매번 새 컨테이너에서 돌리므로 다시 돌려도 같게 나왔지만, 서버에 먼저 붙은 연결이 있으면 달라집니다).
--- 1. processlist at 03:23:54
+----+---------------------------------+------------------------------------+
ID | STATE | INFO |
+----+---------------------------------+------------------------------------+
5 | User sleep | SELECT SLEEP(20) |
6 | Waiting for table metadata lock | ALTER TABLE lab.t ADD COLUMN c INT |
7 | Waiting for table metadata lock | SELECT COUNT(*) FROM lab.t |
+----+---------------------------------+------------------------------------+
--- 2. granted metadata locks
+-----------+-------------------------+---------------+----------------------+--------------+------------+
THREAD_ID | LOCK_MODE | LOCK_DURATION | LOCK_TYPE | TABLE_SCHEMA | TABLE_NAME |
+-----------+-------------------------+---------------+----------------------+--------------+------------+
5 | MDL_SHARED_READ | transaction | Table metadata lock | lab | t |
6 | MDL_BACKUP_ALTER_COPY | statement | Backup lock | | |
6 | MDL_INTENTION_EXCLUSIVE | transaction | Schema metadata lock | lab | |
6 | MDL_SHARED_UPGRADABLE | transaction | Table metadata lock | lab | t |
+-----------+-------------------------+---------------+----------------------+--------------+------------+
A(5번)는 SLEEP 중이라 t를 읽고 있지도 않은데 SR을 transaction 길이로 쥐고 있습니다. B(6번)는 SU를 받았고(표의 SU×SR = +), C(7번)는 받은 잠금이 없습니다. 같은 시점에 하나 더, lock_wait_timeout을 2초로 줄인 D를 보냈습니다.
--- 3. D gives up after lock_wait_timeout=2
ERROR 1205 (HY000) at line 1: Lock wait timeout exceeded; try restarting transaction
B와 C가 정확히 어디서 기다리는지는 gdb로 모든 스레드의 백트레이스를 찍어 MDL_wait::timed_wait가 든 스레드만 골랐습니다(bt-waiting.sh, 주소와 인자는 지우고 #4~#14만 남겼습니다).
#4 MDL_wait::timed_wait at sql/mdl.cc:1874
#5 MDL_context::acquire_lock at sql/mdl.cc:3018
#6 MDL_context::upgrade_shared_lock at sql/mdl.cc:3263
#7 mysql_inplace_alter_table at sql/sql_table.cc:8570
#8 mysql_alter_table at sql/sql_table.cc:12206
#9 Sql_cmd_alter_table::execute at sql/sql_alter.cc:705
#10 mysql_execute_command at sql/sql_parse.cc:5947
#11 mysql_parse at sql/sql_parse.cc:8005
#12 dispatch_command at sql/sql_parse.cc:1903
#13 do_command at sql/sql_parse.cc:1437
#14 do_handle_one_connection at sql/sql_connect.cc:1514
#4 MDL_wait::timed_wait at sql/mdl.cc:1874
#5 MDL_context::acquire_lock at sql/mdl.cc:3018
#6 open_table_get_mdl_lock at sql/sql_base.cc:1712
#7 open_table at sql/sql_base.cc:2128
#8 open_and_process_table at sql/sql_base.cc:4442
#9 open_tables at sql/sql_base.cc:4963
#10 open_and_lock_tables at sql/sql_base.cc:6047
#11 open_and_lock_tables at sql/sql_base.h:545
#12 execute_sqlcom_select at sql/sql_parse.cc:6186
#13 mysql_execute_command at sql/sql_parse.cc:4038
#14 mysql_parse at sql/sql_parse.cc:8005
위가 B, 아래가 C입니다. B는 1절에서 본 Sql_cmd 길(5947행)로 들어가 mysql_inplace_alter_table()에서 SU를 X로 올리려다(upgrade_shared_lock()) 기다립니다. C는 이 글의 2·3절을 그대로 지나 — execute_sqlcom_select() 6186행 → open_and_lock_tables() → open_table() 2128행 — 자기 SR을 받으려다 기다립니다. A가 커밋하자 둘 다 1초 안에 끝났습니다.
--- 5. waiting for A to commit
A committed at 03:24:10
B ALTER done at 03:24:11
C SELECT done at 03:24:11
운영에서 "ALTER 하나 걸었더니 그 테이블의 모든 쿼리가 멈췄다"가 이 그림입니다. 원인은 ALTER가 아니라 그 앞의 열린 트랜잭션 A입니다.
헷갈리기 쉬운 곳: 순식간에 끝나는 ALTER도 X는 필요합니다
ADD COLUMN c INT는 InnoDB가 데이터를 고치지 않고 정의만 바꾸는 INSTANT 방식으로 할 수 있는 변경입니다(따로 돌려 보니 같은 문장에 ALGORITHM=INSTANT를 붙여도 성공하고 Innodb_instant_alter_column이 0에서 1이 됩니다). 실제 일은 금방 끝납니다. 그런데도 기다렸습니다. 백트레이스의 sql_table.cc 8570행이 이유입니다.
else if (inplace_supported == HA_ALTER_INPLACE_COPY_LOCK ||
inplace_supported == HA_ALTER_INPLACE_COPY_NO_LOCK ||
inplace_supported == HA_ALTER_INPLACE_NOCOPY_LOCK ||
inplace_supported == HA_ALTER_INPLACE_NOCOPY_NO_LOCK ||
inplace_supported == HA_ALTER_INPLACE_INSTANT)
{
/*
Storage engine has requested exclusive lock only for prepare phase
and we are not under LOCK TABLES.
Don't mark TABLE_SHARE as old in this case, as this won't allow opening
of table by other threads during main phase of in-place ALTER TABLE.
*/
if (thd->mdl_context.upgrade_shared_lock(table->mdl_ticket, MDL_EXCLUSIVE,
thd->variables.lock_wait_timeout))
goto cleanup;
INSTANT든 온라인(NO_LOCK)이든 준비 단계에서 잠깐은 X를 받아야 합니다. 그 "잠깐"이 앞의 트랜잭션이 끝날 때까지 늘어날 수 있다는 것입니다. 운영에서 스키마를 바꿀 때 SET SESSION lock_wait_timeout=…을 짧게 주고 실패하면 다시 시도하는 이유가 이것입니다.
헷갈리기 쉬운 곳: MDL 교착 탐지는 MDL 안쪽만 봅니다
find_deadlock()이 보는 대기 그래프에는 MDL 대기만 들어 있습니다. InnoDB 행 잠금 대기가 섞인 순환은 보지 못합니다. open_table_get_mdl_lock()의 주석이 그런 예(연결 셋이 MDL과 행 잠금을 엇갈려 기다리는 경우)를 들고 "이런 순환은 지금은 timeout으로만 풀린다(innodb_lock_wait_timeout이나 lock_wait_timeout)"고 적어 둡니다(sql_base.cc 1697–1707행). 이 경우는 예제로 만들어 보지는 않았습니다.
헷갈리기 쉬운 곳: METADATA_LOCK_INFO에는 기다리는 쪽이 안 보입니다
예제의 2번 표에 C(7번)가 없었고, B도 기다리는 X는 없이 받은 SU만 나왔습니다. 플러그인의 채우기 함수가 받지 못한 표를 건너뛰기 때문입니다(if (!granted) DBUG_RETURN(0);, plugin/metadata_lock_info/metadata_lock_info.cc 80–81행). 누가 기다리는지는 PROCESSLIST의 STATE로 봐야 합니다. 또 LOCK_DURATION 열은 Debug 빌드에서만 채워지고(#ifndef DBUG_OFF, 같은 파일 85–91행) 보통 빌드에서는 NULL입니다. 이 글의 출력에 transaction이 보이는 것은 예제 서버가 Debug 빌드이기 때문입니다.
5. 이름 찾기 — JOIN::prepare()와 fix_fields()
테이블이 열렸으니 이제 Item 나무의 이름들을 실제 열에 이을 수 있습니다. mysql_select()가 부르는 JOIN::prepare()의 앞부분이 그 일을 차례로 합니다.
/* Check that all tables, fields, conds and order are ok */
if (!(select_options & OPTION_SETUP_TABLES_DONE) &&
setup_tables_and_check_access(thd, &select_lex->context, join_list,
tables_list, select_lex->leaf_tables,
false, SELECT_ACL, SELECT_ACL, false))
DBUG_RETURN(-1);
// ...
if (setup_wild(thd, tables_list, fields_list, &all_fields, select_lex, false))
DBUG_RETURN(-1);
// ...
if (setup_fields(thd, ref_ptrs, fields_list, select_lex->item_list_usage,
&all_fields, &select_lex->pre_fix, 1))
DBUG_RETURN(-1);
thd->lex->current_select->context_analysis_place= save_place;
if (setup_without_group(thd, ref_ptrs, tables_list,
select_lex->leaf_tables, fields_list,
all_fields, &conds, order, group_list,
select_lex->window_specs,
select_lex->window_funcs,
&hidden_group_fields))
DBUG_RETURN(-1);
setup_tables_and_check_access()→setup_tables()는FROM의 테이블에 0번부터 번호를 매기고,setup_table_map()이table->map = 1 << tablenr로 테이블마다 비트 하나를 줍니다(sql_base.h373–374행). 나중에 옵티마이저가 "이 조건은 어느 테이블들을 쓰나"를 비트 OR로 계산하는 바탕입니다(8편). 인덱스 힌트(USE INDEX)도 여기서 실제 인덱스에 잇습니다. 뷰 안의 테이블이면 권한을 한 번 더 봅니다.setup_wild()는SELECT *의*를 실제 열 목록으로 펼칩니다(insert_fields()). 2절의 "SELECT *는 1142" 오류가 여기서 납니다.EXISTS (SELECT * …)안의*는 펼치지 않고 상수 1로 바꿔 버립니다(8478–8488행) — 어차피 행이 있는지만 보기 때문입니다.setup_fields()가SELECT목록의Item마다fix_fields()를 부르고,setup_without_group()이WHERE(setup_conds()),ORDER BY(setup_order()),GROUP BY, 윈도 함수를 같은 식으로 처리합니다.
Item_field::fix_fields() — 이름 하나를 찾는 순서
fix_fields()는 가상 함수라 Item_func_eq(=)라면 인자들의 fix_fields()를 부르고, 결국 잎인 Item_field에 닿습니다. Item_field::fix_fields()의 첫 일이 find_field_in_tables()입니다.
if ((from_field= find_field_in_tables(thd, this,
context->first_name_resolution_table,
context->last_name_resolution_table,
context->ignored_tables,
reference,
thd->lex->use_only_table_context ?
REPORT_ALL_ERRORS :
IGNORE_EXCEPT_NON_UNIQUE,
!any_privileges,
TRUE)) ==
보통은 IGNORE_EXCEPT_NON_UNIQUE로 부릅니다. "못 찾은 것은 아직 오류로 내지 말고, 둘 이상에서 찾은 것만 오류로 내라"는 뜻입니다. 못 찾았으면 아직 볼 곳이 남아 있기 때문입니다. find_field_in_tables()는 이름 찾기 문맥(Name_resolution_context)에 걸린 테이블들을 차례로 돌며 find_field_in_table_ref()로 찾고, 두 번째로 찾으면 그 자리에서 1052를 냅니다.
if (unlikely(found))
{
if (report_error == REPORT_ALL_ERRORS ||
report_error == IGNORE_EXCEPT_NON_UNIQUE)
my_error(ER_NON_UNIQ_ERROR, MYF(0),
table_name ? item->full_name() : name.str, thd_where(thd));
return (Field*) 0;
}
found= cur_field;
못 찾았으면 fix_fields()는 두 곳을 더 봅니다. SELECT 목록의 별칭(select->is_item_list_lookup이 켜져 있을 때만, 6729행부터)과 바깥 쿼리의 테이블(fix_outer_field() — 상관 서브쿼리의 outer.col이 이렇게 찾아집니다)입니다. 그래도 없으면 fix_outer_field()가 find_field_in_tables()를 이번에는 REPORT_ALL_ERRORS로 한 번 더 불러 1054를 내게 합니다(item.cc 6511–6528행). 오류의 끝에 붙는 'WHERE'·'ORDER BY' 같은 말은 지금 어느 절을 처리하는지 적어 둔 thd->where를 thd_where()가 글자로 바꾼 것입니다(sql_class.cc 667–719행).
빨간 상자가 우리가 늘 보던 두 오류입니다. 어느 칸에서 나는지가 다릅니다.
예제 4: 1054와 1052, 그리고 그 호출 스택
ex/u07-name-resolution은 t1(id, name)과 t2(id, t1_id) 두 테이블에 일부러 틀린 문장을 보냅니다(mariadb --force로 오류가 나도 다음 문장으로 넘어가게 했습니다. 단계 표시 표와 다시 찍힌 문장은 줄였습니다).
--- 1. unknown column in the select list
ERROR 1054 (42S22) at line 4: Unknown column 'nope' in 'SELECT'
--- 2. unknown column in WHERE / ORDER BY
ERROR 1054 (42S22) at line 6: Unknown column 'nope' in 'WHERE'
ERROR 1054 (42S22) at line 7: Unknown column 'nope' in 'ORDER BY'
--- 3. ambiguous column (both t1 and t2 have id)
ERROR 1052 (23000) at line 9: Column 'id' in SELECT is ambiguous
--- 4. qualified with a table that is not in FROM
ERROR 1054 (42S22) at line 11: Unknown column 't3.id' in 'SELECT'
--- 5. a select-list alias: ORDER BY yes, WHERE no
+---+
k |
+---+
1 |
2 |
+---+
ERROR 1054 (42S22) at line 14: Unknown column 'k' in 'WHERE'
--- 6. unknown table (this one fails earlier, in open_tables)
ERROR 1146 (42S02) at line 16: Table 'lab.no_such_table' doesn't exist
같은 1054인데 끝의 말이 'SELECT'·'WHERE'·'ORDER BY'로 다릅니다 — thd->where입니다. 3번은 t1에서도 t2에서도 id를 찾았으니 1052입니다. 4번은 FROM에 없는 t3를 붙였는데도 1052나 "모르는 테이블"이 아니라 1054입니다.
1054가 정말 위에서 말한 길로 나는지 gdb로 확인했습니다. find_field_in_tables() 안에서 ER_BAD_FIELD_ERROR를 내는 7475행에 멈춰 두고 SELECT id FROM t1 WHERE nope = 1을 보냈습니다.
#0 find_field_in_tables at sql/sql_base.cc:7475
#1 Item_field::fix_outer_field at sql/item.cc:6521
#2 Item_field::fix_fields at sql/item.cc:6808
#3 Item::fix_fields_if_needed at sql/item.h:1162
#4 Item_func::fix_fields at sql/item_func.cc:394
#5 Item::fix_fields_if_needed at sql/item.h:1162
#6 Item::fix_fields_if_needed_for_scalar at sql/item.h:1171
#7 Item::fix_fields_if_needed_for_bool at sql/item.h:1175
#8 setup_conds at sql/sql_base.cc:9456
#9 setup_without_group at sql/sql_select.cc:970
#10 JOIN::prepare at sql/sql_select.cc:1619
#11 mysql_select at sql/sql_select.cc:5432
#12 handle_select at sql/sql_select.cc:652
#13 execute_sqlcom_select at sql/sql_parse.cc:6266
아래에서 위로 읽으면 이 글의 길 그대로입니다. execute_sqlcom_select() 6266행의 handle_select() → JOIN::prepare() 1619행의 setup_without_group() → setup_conds()가 WHERE의 nope = 1에 fix_fields() → =의 Item_func::fix_fields()가 인자 nope에 → Item_field::fix_fields() → 테이블에서 못 찾고, 별칭도 아니라서 fix_outer_field()까지 간 뒤에야 → find_field_in_tables()가 오류를 냅니다. 처음 부른 find_field_in_tables()(6714행)가 아니라 두 번째 부른 것(6521행)에서 오류가 나는 것이 보입니다.
헷갈리기 쉬운 곳: 별칭은 ORDER BY에서는 되고 WHERE에서는 안 됩니다
예제 5번의 SELECT id AS k FROM t1 ORDER BY k는 되고, … WHERE k = 1은 1054입니다. setup_conds()가 WHERE를 처리하는 동안 별칭 찾기 스위치를 끄기 때문입니다.
select_lex->is_item_list_lookup= 0;
select_lex->context.resolve_in_select_list= false;
WHERE는 행을 하나씩 거르는 조건이라 SELECT 목록이 계산되기 전에 평가됩니다. 그때는 별칭 k의 값이 아직 없습니다. 반대로 ORDER BY는 setup_order() → find_order_in_list()가 SELECT 목록을 먼저 찾아봅니다(sql_select.cc 29829–29831행). SQL 표준의 논리적 처리 순서(FROM → WHERE → SELECT → ORDER BY)가 소스에서는 "어느 절을 처리할 때 별칭 스위치를 켜 두나"로 나타납니다.
헷갈리기 쉬운 곳: 이름 찾기는 한 번만 합니다
준비된 문장(PREPARE)을 여러 번 실행할 때 매번 테이블을 다 돌며 찾으면 낭비입니다. 그래서 찾은 결과를 Item_field에 적어 둡니다 — item->cached_table(어느 TABLE_LIST였나)과 cached_field_index(몇 번째 열이었나)입니다. 다음 실행에서 find_field_in_tables()는 이것부터 봅니다(7239행부터). 소스의 주석은 그 대가도 적어 둡니다. "열이 하나 더해져 준비된 쿼리가 모호해지는 경우를 놓치지만, 그 거래를 받아들인다"(7242–7250행).
이 글에서 뺀 것
- 뷰를 열 때 뷰 정의를 파싱해 끼워 넣는
mysql_make_view():open_table()안의share->is_view갈래,sql_base.cc2199–2234행,sql/sql_view.cc - 파생 테이블(
FROM (SELECT …))과 CTE를 준비하는mysql_handle_derived():sql_base.cc6064–6071행,sql/sql_derived.cc - 트리거·저장 함수가 쓰는 테이블까지 미리 여는 "prelocking":
open_tables()의sroutine_to_open루프,DML_prelocking_strategy FLUSH TABLES나ALTER중에 낡은 정의를 만났을 때 다 닫고 다시 여는 길:Open_table_context::request_backoff_action()·recover_from_failed_open(),tdc_wait_for_old_version()LOCK TABLES아래에서 여는 길(thd->locked_tables_mode)과lock_tables()→mysql_lock_tables()(sql/lock.cc)의THR_LOCK- 백업 잠금(
MDL_key::BACKUP— 예제에 보인MDL_BACKUP_ALTER_COPY)과BACKUP STAGE:sql/backup.cc,mdl.cc의MDL_backup_lock - MDL의 "빠른 길"(
MDL_lock::m_fast_road,try_fast_upgrade()) — 받은 표를m_granted목록 밖에서 따로 다루는 길:sql/mdl.cc - 역할(
SET ROLE)과PUBLIC, 정의자 권한으로 도는 뷰·루틴(Switch_to_definer_security_ctx):sql/sql_acl.cc - 테이블 정의를 엔진에게 물어
.frm을 만드는 "discovery":open_table_def()의GTS_USE_DISCOVERY,ha_discover_table() - 시스템 버저닝(
FOR SYSTEM_TIME)의 조건 추가vers_setup_conds():JOIN::prepare()1516행
정리
| 개념 | 한 줄 요약 |
|---|---|
mysql_execute_command() |
lex->sql_command로 갈라지는 2천 줄짜리 switch. 새 문장은 Sql_cmd::execute()로 |
| 권한 표 | 기동 때 acl_init()·grant_init()이 메모리에 올린다. 문장마다 표를 읽지 않는다. 직접 고치면 FLUSH PRIVILEGES |
| 권한의 세 층 | check_access()(전역·DB) → check_grant()(테이블) → fix_fields() 중 열. 오류 1044 → 1142 → 1143 |
SELECT *와 열 권한 |
모든 열을 볼 권한이 없으면 열 이름을 숨기고 1142로 |
TABLE_SHARE |
테이블 정의. 테이블마다 하나. .frm에서 읽는다. table_definition_cache |
TABLE |
정의로 연 인스턴스(handler, 행 버퍼). 쓰는 쪽마다 하나. 문장 끝에 반납. table_open_cache |
| 상태 변수 | Opened_table_definitions·Opened_tables가 늘면 캐시가 모자란 것. Table_open_cache_hits는 재사용 |
| MDL | 테이블 정의를 지키는 서버 계층 잠금. SELECT는 SR, DML은 SW, ALTER는 SU → X |
| MDL의 길이 | BEGIN 안에서는 트랜잭션 끝까지. TABLE은 문장 끝에 돌려줘도 MDL은 남는다 |
| 줄서기 | X를 기다리는 사람이 있으면 새 SR도 뒤에 선다. 열린 트랜잭션 하나가 ALTER와 그 뒤 모든 쿼리를 멈춘다 |
lock_wait_timeout |
MDL 대기 한도. 기본 하루. 넘으면 1205 |
JOIN::prepare() |
setup_tables(map 비트) → setup_wild(*) → setup_fields → setup_conds·setup_order |
Item_field::fix_fields() |
테이블 → (별칭) → 바깥 쿼리 순으로 찾는다. 둘이면 1052, 없으면 1054 |
다음에 볼 것
이제 JOIN은 어느 테이블의 어느 열을 쓰는지, 조건이 어느 테이블들에 걸리는지(map 비트) 다 압니다. 남은 질문은 "그래서 어떤 순서로, 어떤 인덱스로 읽을까"입니다. 상수 테이블 찾기, 범위 옵티마이저, 조인 순서 고르기, 그리고 EXPLAIN이 만들어지는 곳을 8편: 옵티마이저 — JOIN::optimize와 실행 계획 고르기에서 봅니다. 예제 2에서 슬쩍 열리던 통계 표 세 개가 거기서 쓰입니다.
같은 일을 앞 연재에서도 만났습니다. Tomcat 7편의 Mapper가 URL을 Host·Context·서블릿에 잇고, httpd 10편의 translate_name·map_to_storage가 URL을 디스크 경로와 그 설정에 이었다면, MariaDB의 준비 단계는 SQL의 이름을 TABLE과 Field에 잇습니다. 셋 다 "찾는 일"을 "실행하는 일"보다 먼저 끝내고, 찾은 결과를 요청 객체(MappingData, request_rec, TABLE_LIST·Item_field)에 적어 둔 뒤 다음 단계가 꺼내 씁니다. 권한도 닮았습니다. httpd 13편과 Tomcat 14편에서 인증(누구인가)과 권한(무엇을 해도 되나)이 나뉘어 있었듯, MariaDB도 인증은 연결할 때 한 번(4편의 acl_authenticate()), 권한은 문장마다 이 글의 세 층에서 봅니다.
애플리케이션 쪽에서 보면, Spring 17편의 @Transactional 메서드가 길어질수록 그 안에서 읽은 테이블의 MDL을 오래 쥐게 됩니다. 배포 중에 마이그레이션 도구가 건 ALTER TABLE이 멈춰 서고 그 뒤로 요청이 쌓인다면, 이 글의 예제 3이 그 장면입니다.