[KO] CUBRID 읽기 경로 — SELECT 한 문장의 끝에서 끝까지
목차
- 이 문서가 따라가는 경로
- 1단계 — 클라이언트에서 브로커로
- 2단계 — 서버 쪽 요청 진입
- 3단계 — 파싱, 의미 검사, 재작성
- 4단계 — 옵티마이저, XASL 생성기, XASL 캐시
- 5단계 — 실행기가 연산자 트리를 굴린다
- 6단계 — 스캔 매니저가 접근 방법을 고른다
- 7단계 — heap 또는 B+Tree 접근 방법이 돌아간다
- 8단계 — 행마다 술어 평가
- 9단계 — MVCC 가시성 검사
- 10단계 — 결과를 만들어 내보낸다
- 11단계 — 결과가 클라이언트로 돌아간다
- 다이어그램 — 전체 파이프라인
- 다루지 않은 내용
- 출처
이 문서가 따라가는 경로
섹션 제목: “이 문서가 따라가는 경로”이 문서는 구체적인 질의 하나, SELECT * FROM t WHERE x > 10을 따라간다. JDBC 드라이버가 executeQuery를 부르는 그 순간부터, 마지막 결과 행이 애플리케이션으로 되돌아오는 순간까지가 무대다. 여행은 CUBRID 브로커 데몬 쪽으로 들어오는 TCP 바이트 흐름에서 시작한다. 브로커는 AF_UNIX 소켓으로 미리 마련해 둔 길에 SCM_RIGHTS 보조 메시지를 실어 파일 디스크립터 자체를 CAS 워커 프로세스에 넘긴다. CAS는 CSS 프레임으로 감싸인 NRP 옵코드 하나로 cub_server 요청 핸들러 안으로 들어선다. 그 뒤로는 컴파일 파이프라인 전체 — lexer → bison parser → 의미 검사 → 질의 재작성 → 비용 기반 옵티마이저 → XASL 생성기 → XASL 캐시 — 가 차례로 도는 길이 펼쳐진다. 파이프라인 끝에서 실행기 안으로 들어가면 Volcano 스타일의 연산자 트리가 순차 heap 스캔이나 인덱스 범위 스캔에서 행을 한 줄씩 끌어올린다. 끌어올린 행마다 PRED_EXPR 워커가 x > 10 술어를 검사하고, mvcc_satisfies_snapshot이 MVCC 가시성을 따져 본다. 두 검사를 통과한 행만 list-file에 자리를 잡는다. 커서가 그 list-file을 와이어 너머로 읽어 내고, cub_cas와 cub_broker를 거꾸로 거쳐 결과가 JDBC까지 되돌아온다. 같은 네트워크 프레임이 질의를 들여보내고 결과를 다시 내보낸다. 아래의 단계마다 knowledge/code-analysis/cubrid/ 안에 있는 세부 문서 한두 편을 짚어 둔다. 해당 메커니즘을 정밀하게 다루는 곳은 그 세부 문서의 ## CUBRID's Approach 절이고, 여기 있는 글은 그 위에 얹은 종합이지 코드를 새로 읽은 결과는 아니다.
질의는 일부러 작게 잡았다. SELECT * FROM t WHERE x > 10은 기본 테이블 하나, 부등호 술어 하나로 끝난다. 조인도 없고, 집계도 없고, 정렬도 없다. 덕분에 여행이 엔진의 등뼈를 따라 곧장 흐른다. 이 질의가 거치는 단계는 거의 모든 질의가 거치는 단계이기도 하다. 등뼈를 잇는 일에 글이 집중할 수 있고, 가지치기는 뒤로 미뤄 둘 수 있다. 이 경로에 없는 가지(group-by, 해시 조인, 파티션 프루닝, 병렬 질의, 후처리)들은 끝의 다루지 않은 내용에 한 줄씩 짚어 두었다.
1단계 — 클라이언트에서 브로커로
섹션 제목: “1단계 — 클라이언트에서 브로커로”여행은 JDBC 클라이언트 프로세스에서 시작한다. 애플리케이션이 Statement.executeQuery("SELECT * FROM t WHERE x > 10")을 부르면, JDBC 드라이버는 그 호출을 CCI(CUBRID Call Interface) 요청으로 싸서 TCP 소켓으로 CUBRID 브로커에 밀어 넣는다. 브로커 데몬 cub_broker는 SysV 공유 메모리로 협조하는 부모 프로세스다. 시작할 때 정해진 크기의 cub_cas 워커 프로세스 풀을 fork해 두는데, CAS 한 명 한 명은 자기만의 주소 공간을 가진 독립적인 유닉스 프로세스로, 그 안에 CUBRID 클라이언트 라이브러리가 통째로 들어가 있다. 세부 문서 cubrid-broker.md가 이 토폴로지를 정밀하게 짚어 준다. cub_broker는 공개 TCP 리스너(getenv(PORT_NUMBER_ENV_STR)에 바인드된 sock_fd)를 들고 있고, JDBC 클라이언트 연결은 receiver_thr_f 스레드가 받는다. 받은 디스크립터는 공유 T_SHM_APPL_SERVER 안에 있는 max-heap 정렬의 잡 큐(T_MAX_HEAP_NODE job_queue[])에 줄을 선다. dispatch_thr_f가 find_idle_cas를 들여다보고 CAS 워커 한 명을 골라낸다.
그 다음에 일어나는 일이 이 설계가 가진 가장 또렷한 한 수다. CUBRID는 SQL 트래픽을 한 바이트도 빠짐없이 브로커로 중계하는 대신, 클라이언트 TCP 소켓의 열려 있는 커널 파일 디스크립터 그 자체를 미리 만들어 둔 AF_UNIX 통로 위로 골라낸 CAS에게 넘겨버린다. POSIX의 SCM_RIGHTS 보조 메시지를 쓴다. nginx나 pgbouncer가 쓰는 그 원시 메커니즘이다. send_fd()/recv_fd() 짝이 끝나면 브로커는 자기 쪽 디스크립터 사본을 떨어뜨리고, 이제 CAS가 데이터 평면을 통째로 소유한다. 이 시점부터 JDBC 드라이버와 CAS 워커는 직거래다. 브로커는 다시 관리 역할로 돌아간다. SQL 로그 수집, ACL 적용, 연결 풀 미터링 같은 일들은 공유 메모리 위의 제어 평면으로만 이루어진다. cubrid-dbi-cci.md는 CAS 쪽 핸드셰이크를 따라간다. ux_database_connect는 CUBRID 클라이언트 API의 db_restart(즉 boot_cl.c의 boot_restart_client)를 부른다. 그 호출이 CAS 프로세스에서 요청된 데이터베이스의 cub_server 인스턴스로 향하는 트랜잭션 연결 하나를 연다. 이제 CAS는 소켓을 두 개 들고 있다. 한쪽은 fd 핸드오프로 받은 JDBC 쪽 소켓이고, 다른 한쪽은 서버 쪽 소켓(표준 CSS 연결, cubrid-network-protocol.md 참고)이다.
CAS는 JDBC 쪽 소켓에서 SQL 텍스트를 받아, T_SRV_HANDLE 키로 묶인 ux_prepare/ux_execute 짝에 흘려 넣는다. 두 함수 안쪽에서 임베드된 db_* API가 풀려나간다. db_open_buffer가 DB_SESSION 하나를 만들어 내고, db_compile_statement_local이 컴파일 파이프라인(Initial → Compiled → Prepared, FSM은 session->stage[]에 들어 있다)을 돌리고, db_execute_statement_local이 그것을 Prepared → Executed로 뒤집는다. prepare와 execute가 갈라져 있는 덕에, JDBC 클라이언트가 PreparedStatement를 쓰는 경우 CAS는 컴파일된 DB_SESSION 하나를 여러 번의 executeQuery에 걸쳐 다시 쓸 수 있다. 우리 예시는 SQL 텍스트가 그대로 박혀 들어오는 실행이므로, prepare와 execute가 같은 ux_execute 호출 안에서 등을 맞대고 돈다. cubrid-dbi-cci.md가 진실의 출처다. 모든 바인딩(JDBC, CCI 네이티브, ODBC, Python, PHP, CSQL)이 어떻게 같은 db_* 코어 위로 모이는지를 짚어 둔다. 브로커의 CAS는 그 위에 얹힌 와이어 드라이버 래퍼일 뿐이다. broker/cas.c의 평평한 server_fn_table을 거쳐 CCI 옵코드(cas_protocol.h의 CAS_FC_*)를 db_* 호출로 옮긴다.
2단계 — 서버 쪽 요청 진입
섹션 제목: “2단계 — 서버 쪽 요청 진입”이제 서버가 컴파일하고 실행해 줘야 한다. CAS는 네트워크 관점에서 보면 CUBRID 클라이언트다. 임베드된 csql이 쓰는 것과 똑같은 network_cl.c 스텁을 돌리며, 컴파일/실행 일거리를 CSS 프레임 계층으로 cub_server로 넘겨 보낸다. cubrid-network-protocol.md가 프레임 모양을 정밀하게 다룬다. 서버 진입점은 모두 중앙 enum(network.h의 enum net_server_request) 안의 NET_SERVER_* 옵코드 하나로 표현된다. 서버 쪽에서는 시작할 때 net_server_init()이 채우는 정적 테이블(network_sr.c의 static struct net_request net_Requests[])을 거쳐 디스패치된다. 테이블의 행마다 함수 포인터와 속성 비트마스크 — CHECK_DB_MODIFICATION, CHECK_AUTHORIZATION, IN_TRANSACTION — 가 함께 적혀 있어 그 호출의 사전 조건을 선언해 둔다. 와이어 프레임 자체는 길이 접두 형식이다. NET_HEADER 구조체가 본문 앞에 붙고, pack과 unpack은 대칭이다(or_pack_int, or_unpack_int, or_pack_value 같은 짝). 클라이언트 스텁과 서버 핸들러가 거울처럼 마주 보고 있는 셈이다.
서버 쪽에서 연결을 받는 일은 cub_master가 한다. cub_master는 새 연결을 유닉스 도메인 소켓 위의 master::connector를 거쳐 요청된 cub_server로 흘려보낸다. 그 자리부터 연결의 주인은 epoll 기반의 cubconn::connection::worker다. 이 워커가 CSS 프레임 패킷을 읽어 디코딩한 요청을 net_Requests[]로 밀어 넣는다. 우리 SELECT에서 알맞은 옵코드는 NET_SERVER_QM_QUERY_PREPARE다(JDBC가 PreparedStatement.executeQuery가 아니라 Statement.executeQuery를 던졌을 때 쓰이는, prepare와 execute가 합쳐진 경로다). 핸들러는 SQL 텍스트와 host variable을 풀어내어 질의 매니저로 흘려보낸다.
컴파일 작업이 시작되기 전에, 서버는 이 요청을 자기 세션과 트랜잭션 컨텍스트에 묶어 둬야 한다. cubrid-server-session.md가 그 일을 자세히 풀어 둔다. CSS_CONN_ENTRY 하나하나에는 이미 session_id와 캐시된 session_p 포인터가 들어 있다. 새 연결의 첫 요청은 xsession_check_session을 돌려 서버 전역의 락-프리 해시(SESSION_ID로 키를 잡은)에서 SESSION_STATE를 끄집어낸다. 같은 소켓에서 들어오는 그 다음 요청들은 해시 조회를 또 치를 일이 없다. 캐시된 포인터를 곧장 읽어 쓴다. 세션은 장부 같은 그릇이다. 준비된 구문, autocommit, 마지막 insert id, 로케일 같은 것을 담고, 여러 트랜잭션을 건너뛰며 살아남는다. 한편 트랜잭션은 락 매니저와 복구 매니저가 따로 들고 있는 별개의 객체다. cubrid-transaction.md는 이 객체를 TDES(transaction descriptor)라고 부른다. 서버 전역 trantable 안에 트랜잭션마다 한 줄씩 있는 레코드로, 변하지 않는 trid, 라이프사이클 상태, 격리 수준, MVCC 스냅샷을 들고 있다. 연결 계층은 연결마다 트랜잭션 인덱스 하나를 들고 있다. 워커 스레드는 그 인덱스를 자기 THREAD_ENTRY로 베껴 둔다. 그래야 LOG_FIND_THREAD_TRAN_INDEX(thread_p)가 옳은 LOG_TDES를 돌려준다. 이 묶기가 끝나면, 그 아래의 모든 계층 — 파서, 옵티마이저, 실행기, 스캔 매니저, MVCC 검사기 — 은 thread_p 하나로 SESSION_STATE와 LOG_TDES에 자동으로 닿을 수 있다.
3단계 — 파싱, 의미 검사, 재작성
섹션 제목: “3단계 — 파싱, 의미 검사, 재작성”질의 매니저는 날 SQL 텍스트를 컴파일 프런트엔드로 넘긴다. cubrid-parser.md가 lexer와 parser를 따라간다. lexer는 csql_lexer.l에서 Flex로 만들어진 것이다. yylex()는 호출 한 번에 (token_class, lexeme) 짝 하나를 돌려주고, 단일 버퍼 YY_INPUT을 따라 움직인다. parser는 csql_grammar.y에서 Bison으로 만들어지고 %glr-parser로 컴파일된다. 즉 그래머 충돌 자리에서 가지를 쳐 두었다가, 환원에 실패한 가지는 버리는 일반화된 LR 변종이다. SQL에는 LALR(1)이 깔끔히 처리하지 못하는 오래된 모호성이 있기 때문이다(GROUP BY c, d 목록 대 튜플 파싱이 그 고전적 예다). 그래머의 reduce 동작은 parser_new_node를 불러 PARSER_CONTEXT별 블록 할당기에서 PT_NODE 객체를 따낸다. 덕분에 파스 트리 전체를 나중에 한 번의 패스로 풀어 줄 수 있다. 결과는 사용자가 적은 모양 그대로의 PT_NODE 트리다. 루트는 PT_SELECT로, t에 대한 PT_SPEC을 가리킨다. 투영 목록 자리에는 PT_NAME(*은 나중에 펼쳐진다)이 있고, WHERE에는 옵코드가 >인 PT_EXPR이 자리 잡는다. 이 PT_EXPR의 피연산자는 x에 대한 PT_NAME과 10에 대한 PT_VALUE다.
방금 파싱이 끝난 트리는 문법은 맞지만, 카탈로그를 두고 본 의미는 아직 맞춰지지 않았다. cubrid-semantic-check.md는 pt_check_with_info가 잇따라 부르는 네 패스를 짚어 준다. (1) 이름 해석은 SCOPES 스택(linked list, 머리가 가장 안쪽)을 따라 트리를 걷고, PT_NAME 하나하나를 그것을 공급하는 PT_SPEC에 묶어 준다. x의 경우, t의 컬럼들을 카탈로그에서 가져와 컬럼 디스크립터를 PT_NAME 노드에 꿰어 둔다. (2) WHERE 절 집계 검사는 술어 안에 묶이지 않은 집계가 끼어 있지는 않은지 본다. 여기서는 할 일이 없다. (3) 호스트 변수 치환은 prepared statement 파라미터를 박아 넣는 단계인데, 우리 텍스트 박힌 질의에는 해당이 없다. (4) semantic_check_local이 pt_semantic_type을 불러 타입 평가를 돌린다. > 연산자의 왼쪽 피연산자 x는 카탈로그에서 끌어온 컬럼 타입을 가지고, 오른쪽 피연산자 10은 정수 리터럴이다. 타입 검사기는 pt_apply_expressions_definition에서 EXPRESSION_DEFINITION 오버로드를 골라내고, 묵시적 승급이 필요하면 PT_CAST를 끼워 넣는다. 상수 폴딩도 여기서 일어난다. WHERE 1=1 AND x > 10 같은 술어라면 첫 결합자를 TRUE로 접고 그대로 떨어뜨린다. 마지막으로 pt_cnf가 WHERE 절을 결합 정규형(CNF)으로 다시 짠다. 결합자 하나하나가 인덱스로 풀 수 있는 후보가 되도록 길을 깔아 두기 위함이다.
의미 검사가 끝난 트리는 재작성 계층으로 흘러 들어간다. cubrid-query-rewrite.md가 그 변환 목록을 정리해 둔다(query_rewrite.c의 mq_rewrite가 진입점이다). 술어 푸시다운, 뷰 인라인, 서브쿼리 평탄화, outer join 축소, 잉여 join 제거, 자동 매개변수화, LIMIT 절 끌어내리기 사례 연구가 모두 거기에 들어 있다. 우리 SELECT에는 무거운 변환이 하나도 걸리지 않는다. 뷰도 없고, 서브쿼리도 없고, 조인도 없고, LIMIT도 없다. 그래도 재작성 단계는 그대로 돌아 정규화된 모양을 만들어 낸다. XASL 캐시 키가 텍스트만 다를 뿐 같은 뜻인 변형들 사이에서도 흔들리지 않게 하기 위해서다. cubrid-xasl-cache.md는 캐시가 사용자가 적은 날 텍스트가 아니라 재작성된 SQL(hash text)을 해시한다고 명시한다. 그래서 SELECT * FROM t WHERE x>10과 SELECT * FROM t WHERE x > 10이 같은 캐시 슬롯에서 부딪힌다.
4단계 — 옵티마이저, XASL 생성기, XASL 캐시
섹션 제목: “4단계 — 옵티마이저, XASL 생성기, XASL 캐시”옵티마이저는 이름이 풀린 정규형 PT_NODE를 비용이 매겨진 계획으로 끌어내린다. cubrid-query-optimizer.md가 그 끌어내림을 짚어 준다. qo_optimize_query는 QO_ENV 질의 그래프를 짠다. FROM의 출처 하나가 QO_NODE가 되고, 술어 하나가 QO_TERM이 되며, 컬럼 참조 하나가 QO_SEGMENT가 된다. 비용 모델은 통계가 끌고 간다. cubrid-statistics.md가 통계의 모양을 정확히 그려 둔다. 서버 쪽 xstats_update_statistics가 heap과 B+Tree를 훑으며, 카디널리티, NDV(서로 다른 값의 수), 리프/페이지 수, 부분 키 펼침 폭을 카탈로그의 최신 디스크 표현 위에 적어 둔다. 클라이언트는 qo_get_attr_info로 그 통계를 읽어, qo_iscan_cost / qo_sscan_cost로 계획의 점수를 매기고, qo_equal_selectivity / qo_range_selectivity로 술어 선택도를 가늠한다. 비용 모델 자체는 System R 모양이다. 고정된 CPU/IO 시작 비용에, 추정 카디널리티로 곱해지는 행 단위 비용이 더해지는 형태다.
이 질의에서 플래너가 풀어야 할 것은 단일 테이블의 접근 경로 선택이다. heap 스캔이냐, x를 덮는 인덱스의 인덱스 스캔이냐. t에 x를 키로 하는 인덱스가 있다면, 플래너는 x > 10에 대한 qo_range_selectivity 값을 사이에 두고 qo_sscan_cost(순차 heap 스캔, 모든 페이지를 다 읽는다)와 qo_iscan_cost(B+Tree 범위 하강 뒤 OID마다 heap 페치)를 견준다. 선택도가 작으면 인덱스 스캔이 유리하고, 선택도가 크면 무작위 heap 읽기 비용이 절약되는 페이지 수를 잡아먹어 heap 스캔이 유리하다. 쓸 만한 인덱스가 없다면 길은 하나뿐이다. qo_search_partition_join의 DP 조인 열거는 단일 테이블에서는 빈 일이고, 옵티마이저는 골라낸 접근 스펙 하나가 박힌 단일 QO_PLAN으로 정리한다. (테이블이 8개를 넘으면 CUBRID는 완전 DP에서 부분 조인 첫 노드 잡고 이어붙이기 휴리스틱으로 갈아타지만, 여기서는 상관없는 이야기다.)
골라낸 QO_PLAN은 절차적이지만 아직 실행 가능한 모습은 아니다. 그것을 실행기가 부를 수 있는 모양으로 바꿔 주는 일이 cubrid-xasl-generator.md의 몫이다. xasl_generation.c는 gen_outer/gen_inner로 계획을 걸으며 XASL_NODE 트리를 짜낸다. 우리 SELECT의 루트는 BUILDLIST_PROC XASL이다. 그 안의 spec_list에는 테이블 t에 대한 ACCESS_SPEC_TYPE 하나가 들어가고(where_pred에 x > 10이 박힌다), outptr_list는 모든 컬럼을 투영한다(*은 의미 검사 단계에서 이미 펼쳐져 있다). aptr_list와 dptr_list는 비어 있고(서브쿼리가 없다), scan_ptr도 NULL이다(이어 붙은 조인이 없다). 술어 x > 10은 옵코드 T_PRED의 PRED_EXPR이 되며, 그 아래 T_EVAL_TERM 잎에서 TYPE_ATTR_ID 종류의 REGU_VARIABLE(x의 컬럼 참조)과 TYPE_DBVAL 종류의 REGU_VARIABLE(상수 10)을 견준다. 트리 전체는 xasl_to_stream.c(xts_* 패밀리)가 오프셋 테이블을 깐 자기 설명형 바이트 버퍼로 직렬화한다. 포인터는 오프셋으로 평평하게 펴지고, 공유되는 부분 트리는 한 번만 적힌다.
cubrid-xasl-cache.md는 그 다음 일을 풀어 준다. 직렬화된 스트림의 hash text를 SHA-1로 묶어 XASL_ID를 만들고, 서버 전역의 latch-free 해시맵을 두드린다. 들어맞으면(hit) 캐시된 XASL_CACHE_ENTRY를 다시 쓴다. 같은 SQL이 다음번 들어왔을 때 파싱부터 여기까지의 일을 통째로 건너뛸 수 있다는 뜻이다. 빗나가면(miss) 그 자리에 새로 끼워 넣는다. 캐시 항목은 직렬화된 계획, 클래스 OID 의존 목록(DDL이 떨어지면 xcache_remove_by_oid가 그 의존자들을 무효화한다), 컴파일 시점의 통계 스냅샷(드리프트가 감지되면 부드럽게 RT 재컴파일을 깨운다), 참조 카운트와 축출 메타데이터, time_stored 타임스탬프를 모두 들고 다닌다. 이 질의의 첫 실행은 빗나가는 쪽으로 떨어진다. 컴파일하고, 캐시에 끼워 넣고, XASL_ID를 돌려준다. 그 뒤 같은 질의가 다시 들어오면 캐시에 들어맞아 곧장 5단계로 건너뛴다.
5단계 — 실행기가 연산자 트리를 굴린다
섹션 제목: “5단계 — 실행기가 연산자 트리를 굴린다”XASL_ID를 손에 쥔 서버 질의 매니저는 실행기로 들어선다. cubrid-query-executor.md가 진입 사슬을 정밀하게 짚어 둔다. xqmgr_execute_query가 XASL_ID로 캐시된 XASL을 끌어내고, host-variable 버퍼를 풀어내어 qmgr_process_query를 부른다. 이 함수는 XASL 스트림을 인메모리 XASL_NODE 트리로 풀어내고(이미 트리 모양이 아니라면) qexec_execute_query를 부른다. 후자는 XASL_STATE를 짜낸다(host variable과 timezone을 든 VAL_DESCR을 안에 갖는다). 그러고는 qexec_execute_mainblock으로 흘려보낸다. 이 함수가 모든 것 — 서브쿼리, CTE, 조인 블록 — 이 결국 다시 들어오는 재귀 진입점이다. 이어서 qexec_execute_mainblock_internal이 proc 종류별 switch (xasl->type)를 돌린다. 거기에는 UPDATE_PROC, DELETE_PROC, INSERT_PROC, MERGE_PROC, CTE_PROC, DO_PROC 같은 것들이 있다. 그러나 우리 SELECT의 루트는 BUILDLIST_PROC이고, 일반적인 풀링 인터프리터 qexec_intprt_fnc로 떨어진다.
이 인터프리터는 글자 그대로의 Volcano 모양이다. 가드 링이 셋이다. 바깥쪽 루프는 qexec_next_scan_block_iterations이며(매 반복마다 SCAN_ID들을 새 접근 스펙 조합으로 초기화한다), 가운데 루프는 현재 블록 안의 튜플을 두고 도는 scan_next_scan, 가장 안쪽의 행 본체에서는 bptr_list(경로 표현 페치), dptr_list(상관 서브쿼리), scan_ptr(이어 붙은 조인 블록, 재귀), if_pred(스캔으로 밀어 넣지 못한 잔여 술어)가 도는 식이다. 바깥 두 루프가 qexec_intprt_fnc라는 이름의 출처고, 안쪽 본체가 행 단위 일을 한다. 우리처럼 단일 테이블에 술어만 있는 질의에서는 안쪽 본체가 거의 비어 있다. 경로 표현도 없고, 상관 서브쿼리도 없고, scan_ptr 사슬도 없다. 결국 루프는 이렇게 줄어든다. 루트 스캔을 열고, 다음 튜플을 끌어올리고, 술어를 검사하고, 통과하면 xasl->list_id에 붙이고, 또 반복한다. Database Internals(Petrov, 12장)가 Volcano 스타일의 filter-over-scan이라 부르는 모양 그대로다. 다만 CUBRID 특유의 세 겹 발판이 둘러져 있을 뿐이다.
루트 스캔을 열기 위해, 실행기는 spec에 대고 qexec_open_scan을 부른다(그 아래에서는 scan_open_<type>_scan이 scan_manager.c로 흩어진다). 돌아오는 것은 채워진 SCAN_ID다. type 판별자가 태그된 union의 어느 팔이 살아 있는 상태를 들고 있는지를 가른다. status 필드는 S_OPENED에서 출발한다. 그 다음 호출(scan_start_scan)이 시작 페이지를 잡고, heap이라면 HEAP_SCANCACHE를, 인덱스라면 BTREE_SCAN을 따낸 뒤 MVCC 스냅샷을 찍어 두면서 상태가 S_STARTED로 한 칸 옮겨간다. 그 뒤 안쪽 Volcano 루프는 S_END가 떨어질 때까지 scan_next_scan을 거듭 부르고, 끝나면 scan_end_scan과 scan_close_scan이 SCAN_ID를 풀어낸다. 라이프사이클은 교과서적인 다섯 걸음, 즉 S_OPENED → S_STARTED → S_STARTED → S_ENDED → S_CLOSED 차례다. heap-scan 강의 슬라이드가 짚어 두던 그 흐름이고, cubrid-query-executor.md와 cubrid-scan-manager.md가 짝을 맞춰 자세히 풀어 두는 흐름이기도 하다.
6단계 — 스캔 매니저가 접근 방법을 고른다
섹션 제목: “6단계 — 스캔 매니저가 접근 방법을 고른다”cubrid-scan-manager.md는 scan_open_<type>_scan과 scan_next_scan 안에서 일어나는 일에 대한 단일한 진실의 출처다. 다형성은 데이터가 끌고 간다. 구조체 SCAN_ID 하나, 판별자 SCAN_TYPE 하나, 공개 함수마다 switch 하나. SCAN_TYPE enum이 어떤 접근 방법이 존재하는지를 정확히 다 늘어놓는다. S_HEAP_SCAN, S_PARALLEL_HEAP_SCAN, S_CLASS_ATTR_SCAN, S_INDX_SCAN, S_LIST_SCAN, S_SET_SCAN, S_JSON_TABLE_SCAN, S_METHOD_SCAN, S_VALUES_SCAN, S_SHOWSTMT_SCAN, S_HEAP_SCAN_RECORD_INFO, S_HEAP_PAGE_SCAN, S_INDX_KEY_INFO_SCAN, S_INDX_NODE_INFO_SCAN, S_DBLINK_SCAN, S_HEAP_SAMPLING_SCAN. 목록은 컴파일 시점에 못 박힌다. 새 접근 경로를 더하려면 enum 값 하나, union 안의 하위 구조체 하나, scan_open_* 함수 하나, 그리고 scan_start_scan/scan_next_scan_local/scan_end_scan/scan_close_scan의 switch 팔을 모두 추가해야 한다.
WHERE x > 10에서는 4단계의 옵티마이저 결정이 어느 팔이 켜질지를 이미 정해 두었다. 관련된 두 경우는 다음과 같다.
- heap 스캔 경로 (
S_HEAP_SCAN). 옵티마이저가 순차 접근을 골랐다는 뜻이다.x에 인덱스가 없거나, 술어 선택도가 무뎌 인덱스 접근의 비용(무작위 heap 읽기가 누적되는 비용)이 더 크다고 본 것이다.scan_open_heap_scan이HEAP_SCAN_ID를 채운다.HEAP_SCANCACHE와 시작 OID가 들어간다. 튜플마다scan_next_scan이heap_next(또는 MVCC를 함께 보는heap_next_record)로 heap을 한 칸씩 전진하고, 페치된 레코드마다where_pred(x > 10)를 평가한다. 스캔 안으로 밀어 넣지 못한 술어는xasl->if_pred에 남아 있다가 스캔 뒤에 돈다. - 인덱스 범위 스캔 경로 (
S_INDX_SCAN). 옵티마이저가 인덱스 접근을 골랐다는 뜻이다.x에 인덱스가 있고x > 10이 충분히 좁다고 본 것이다.scan_open_index_scan이INDX_SCAN_ID를 채운다.BTREE_SCAN, OID 버퍼, 그리고 술어 셋 묶음 —range_pred(인덱스 순회의 범위를 잡는다.x > 10이 그 하한이 된다),key_pred(순회 도중 인덱스 안에 들어 있는 컬럼에 대고 평가한다),scan_pred(heap 페치 뒤에 평가한다) — 가 들어간다. 셋 묶음은 각각(regu_list, pr_eval_fnc, ...)형태로INDX_SCAN_ID에 박혀 있다. 튜플마다scan_next_scan은 B+Tree 커서를 다음(key, OID)짝으로 한 칸 옮기고,key_pred를 적용하고, OID를 따라 heap에 들른다(인덱스가 덮는 인덱스라면 — 즉 참조되는 컬럼이 모두 키 안에 있다면 — heap 페치는 통째로 건너뛴다). 끝으로scan_pred를 적용한다.
range_pred/key_pred/scan_pred로 갈라지는 이 모양은, PostgreSQL이 IndexQual/IndexFilter/Filter라 부르는 것, MySQL이 range/ref/Using where라 부르는 것과 같은 가름새다. CUBRID가 자기 식별자를 붙였을 뿐이다. 세부 문서가 INDX_SCAN_ID의 짝을 이루는 필드 배치를 짚어 둔다.
7단계 — heap 또는 B+Tree 접근 방법이 돌아간다
섹션 제목: “7단계 — heap 또는 B+Tree 접근 방법이 돌아간다”스캔 매니저 아래에는 접근 방법 모듈들이 앉아 있다. 이들은 페이지, 슬롯, OID라는 말로 이야기한다. cubrid-page-buffer-manager.md가 그 바닥이다. 페이지 페치는 모두 Buffer Control Block(BCB)에 대고 pgbuf_fix를 거친다. 그 호출이 페이지를 버퍼 풀에 못 박아 두는데, 자체 read/write/flush 래치와 fix 카운트가 함께 따라온다. 버퍼 풀의 세 구역 LRU(스레드별 사적 목록과 공유 목록으로 갈라지고, 그 사이의 비율은 조정 가능하다)가 축출을 정한다. 우리 스캔에서는 잘 뭉친 순차 heap 페이지가 LRU 안에서 따끈하게 머물 가능성이 크다.
heap-스캔 갈래에서는 cubrid-heap-manager.md가 페이지 단위 로직을 짚어 준다. CUBRID의 heap 페이지는 슬롯 방식이다. 작은 고정 헤더, 끝에서 거꾸로 자라는 슬롯 디렉터리, 헤더에서 앞으로 자라는 레코드 본체로 짜여 있다. 행마다 OID가 붙는데, 그 모양은 (file, page, slot)이다. 슬롯이 안정적인 식별자다. 레코드 본체는 압축 도중 자리를 옮기지만, 슬롯 번호는 그대로 머문다. heap_next는 현재 페이지에서 다음 페이지로 HEAP_CHAIN.next_vpid를 따라가고, 페이지 안에서는 슬롯을 한 칸씩 돈다. 슬롯별 디스패치는 record_type을 보고 정한다. REC_HOME(레코드 본체가 슬롯 안에 있다), REC_RELOCATION(다른 슬롯으로 가는 전달 포인터), REC_BIGONE(별도 파일에 있는 overflow 레코드) 등이 있다. 페치된 레코드는 HEAP_CACHE_ATTRINFO::heap_attrinfo_read_dbvalues를 거쳐 속성마다 DB_VALUE 자리로 풀려 들어간다. 술어 평가가 기다리고 있다. MVCC 버전 사슬은 heap 헤더에 닻을 내린다. 레코드 하나하나에 자기 insert MVCCID와 delete MVCCID, 그리고 이전 버전을 가리키는 뒤로 향한 포인터가 박혀 있다. 페이지 위의 버전이 우리 스냅샷에 보이지 않을 때 그 사슬을 따라 걷는 함수가 heap_get_visible_version_internal이다.
인덱스 스캔 갈래에서는 cubrid-btree.md가 노드 단위 로직을 풀어 준다. CUBRID의 B+Tree 노드도 슬롯 방식이다(heap 페이지에서 그 모양을 그대로 물려받았다). 비유일(non-unique) 인덱스의 키는 key || OID 결합으로 저장한다(OID가 중복 키의 타이브레이커 역할을 한다). 유일 인덱스에는 키만 들어가고, OID는 정해진 오프셋 자리에 들어간다(개수가 많으면 키별 OID 목록으로 흘러넘친다). 하강은 latch-coupling 방식이다(부모를 잡고, 자식을 잡고, 부모를 풀어 주는 식, 읽기 경로의 경우다). 쓰기 경로에는 거기에 더해 루트에서 다시 시작 복구가 붙는다. 동시 분할이 우리 하강을 무효화했을 때 그 길을 다시 잡기 위해서다. 우리 x > 10 범위 스캔에서 btree_keyval_search는 10보다 엄격히 큰 가장 작은 키가 들어 있는 리프까지 내려간다. 그 다음 형제 링크 사슬을 앞으로 걸어가며 호출자에게 (key, OID) 짝을 한 쌍씩 넘긴다. 짝마다 OID 하나를 들고 heap을 페치한다(덮는 인덱스라면 그 페치도 건너뛴다). heap 페치 자체는 다시 cubrid-heap-manager.md의 슬롯 순회와 cubrid-page-buffer-manager.md의 페이지 fix를 거친다.
어느 쪽이든 행 하나에 치르는 비용은 비슷하다. heap 페이지 fix 한 번(heap 스캔이면 순차, 인덱스 스캔이면 무작위), 슬롯 역참조 한 번, 접근하는 컬럼마다 heap_attrinfo_read_dbvalues 한 번, 그리고 인덱스 스캔이면 거기에 B+Tree 리프 페이지 fix 한 번이 더 붙는다. 이 마지막 fix는 리프 페이지 하나가 수십에서 수백 개의 (key, OID) 짝을 품고 있어 여러 짝에 걸쳐 비용이 흩어진다.
8단계 — 행마다 술어 평가
섹션 제목: “8단계 — 행마다 술어 평가”접근 방법이 후보 레코드를 DB_VALUE 자리에 풀어내고 나면, 이제 술어가 돌 차례다. cubrid-query-evaluator.md가 디스패처의 진실 출처다. 술어 x > 10은 PRED_EXPR 트리로 표현된다. 두 REGU_VARIABLE을 견주는 T_EVAL_TERM 잎 위에 T_PRED boolean 루트가 얹힌 모양이다. 워커 eval_pred는 그 트리를 세 값 논리 아래에서 걷는다. 노드마다 V_TRUE, V_FALSE, V_UNKNOWN, V_ERROR 가운데 하나를 돌려주고, AND/OR은 SQL 진리표를 따라 짧은 회로로 돈다. 잎마다 fetch_peek_dbval을 불러 REGU_VARIABLE을 DB_VALUE로 풀어 준다. 디스패처가 regu의 type 태그(상수, 속성 페치, list-file 위치, 산술식, 함수 호출, host variable, OID, list-id 등)를 보고 그에 맞는 풀이 경로로 흘려보낸다. 우리 술어에서는 왼쪽 피연산자가 TYPE_ATTR_ID를 거쳐 접근 방법이 방금 채워 놓은 컬럼 자리로 풀리고, 오른쪽 피연산자가 TYPE_DBVAL을 거쳐 상수 10으로 풀린다.
실제 > 비교는 스칼라 함수 라이브러리가 떠맡는다. cubrid-scalar-functions.md가 그 연산자 프리미티브 계층을 풀어 준다. OPERATOR_TYPE 하나하나(여기서는 T_GT)가 qdata_* 산술 디스패처로 갈라져 들어가고, 거기서 다시 DB_TYPE 짝별 변종으로 흩어진다. 두 피연산자의 타입이 어긋나 있다면(예컨대 한쪽이 INTEGER, 다른 쪽이 BIGINT) tp_value_auto_cast가 공통 도메인으로 끌어모아 준다. 그 뒤 타입에 맞춘 비교기가 boolean을 돌려준다. NULL 의미가 그대로 흘러간다. 한쪽이 NULL이면 비교기가 V_UNKNOWN을 내고, WHERE 절에서의 접힘 규칙(QPROC_QUALIFICATION에 박혀 있다)이 그것을 필터 목적으로는 V_FALSE처럼 다룬다. V_TRUE를 돌려준 행만 다음 단계로 넘어간다.
CUBRID는 자주 등장하는 술어 모양을 빠르게 돌도록 따로 짜 둘 수도 있다. eval_fnc는 PRED_EXPR을 들여다보다가 자기가 알아본 모양에 대해서는 재귀 워커를 거치지 않고 통째로 손으로 짠 평가기의 함수 포인터를 돌려준다(이항 동등에 대한 eval_pred_comp0, LIKE에 대한 eval_pred_like6 같은 식이다). x > 10에서 이런 특수화가 이로운 이유는 술어 평가기가 핫 루프 위에 앉아 있기 때문이다. 행 하나에 한 번씩, 행 수만큼 도는 자리다. 거기서 절약되는 가상 호출 비용은 큰 heap에서 꽤 무겁다. 그래도 재귀 워커는 그대로 남는다. 특수화로는 잡기 어려운 복합 술어에 대한 받침대다.
9단계 — MVCC 가시성 검사
섹션 제목: “9단계 — MVCC 가시성 검사”술어를 통과한 행이라고 해서 곧장 결과 행이 되는 것은 아니다. 그 트랜잭션의 스냅샷 아래에서 보여야 한다. cubrid-mvcc.md가 이 모델을 짚어 준다. 레코드 헤더는 모두 (inserted_by_mvccid, deleted_by_mvccid)와 이전 버전을 가리키는 뒤로 향한 포인터를 들고 있다. 트랜잭션은 첫 읽기에서 논리 스냅샷을 한 장 찍는다. 가시성은 그 스냅샷이 든 활성 MVCCID 집합을 두고 mvcc_satisfies_snapshot이 가른다. 어떤 버전이 보인다는 말은 다음의 두 조건을 동시에 만족한다는 뜻이다. 삽입자가 우리 스냅샷이 찍히기 전에 커밋했어야 하고(즉 삽입자가 활성 상태가 아니고, 그 MVCCID가 스냅샷 상한선보다 작아야 한다), 삭제자가 있다면 그것이 아직 활성 상태로 남아 있거나 우리 스냅샷 이후에 커밋했어야 한다.
heap 스캔에서는 heap_next_record(heap_next의 MVCC 인식 변종)가 페치된 레코드마다 heap_get_visible_version_internal을 부른다. 페이지 위의 버전이 우리 스냅샷에 보이면, 그것이 술어 평가기가 만나는 행이다. 보이지 않는 경우(예컨대 우리가 스냅샷을 찍을 당시에도 활성이던 트랜잭션이 끼워 넣은 버전이라면) 버전 사슬을 heap이나 undo 영역으로 뒤로 걸어 보이는 버전을 찾아낸다. 끝까지 못 찾으면 그 행은 통째로 건너뛴다. 인덱스 스캔에서는 가시성 검사가 두 단계로 갈라진다. B+Tree 리프가 후보 OID를 내놓으면, heap 페이지를 페치하고 그제서야 heap 헤더를 두고 mvcc_satisfies_snapshot을 다시 본다. 그리고 인덱스 스캔은 mvcc_select_lock_needed가 켜져 있을 때(즉 SELECT ... FOR UPDATE일 때) locator_lock_and_get_object_with_evaluation을 거쳐 한 번 더 점검한다. 우리 평범한 SELECT *에서는 그 깃발이 꺼져 있고, 행 단위 락도 잡히지 않는다.
가시성 술어는 읽기만 하는 일이라 로그 쓰기를 부르지 않는다. 그러나 vacuum 서브시스템과 손이 맞물린다. 오래 살아 있는 스냅샷이 전역 가장 오래된 가시 MVCCID 워터마크를 그대로 잡아 두면, 죽은 버전을 얼마나 거침없이 거둘 수 있는지가 그만큼 줄어든다. 우리처럼 짧은 SELECT에서는 이것이 문젯거리는 아니다. 하지만 MVCC가 짊어지는 구조적 비용 — vacuum이 가장 오래 머무는 독자에 묶인다는 것 — 이 이 설계가 비차단 읽기를 얻기 위해 받아들이는 교과서적 절충이다. 술어 만족과 MVCC 가시성은 둘 다 통과해야 한다. 어느 한쪽이라도 떨어지면 그 행은 결과에서 조용히 빠진다.
10단계 — 결과를 만들어 내보낸다
섹션 제목: “10단계 — 결과를 만들어 내보낸다”가시적이고 술어를 통과한 행들이 어딘가에 자리를 잡아야 한다. cubrid-list-file.md가 그 자리의 바닥을 풀어 준다. CUBRID에서 모양 갖춘 튜플 흐름은 모두 같은 추상화를 쓴다. 서브쿼리 결과, 정렬 출력, 해시 빌드 쪽, group-by 누산기, 그리고 최종 질의 결과까지. 이 모두가 QFILE_LIST_ID라는 연결된 페이지 추상화이고, 그 아래에 질의별 QMGR_TEMP_FILE이 깔린다. 처음에는 메모리 안의 membuf로 시작했다가, 커지면 FILE_TEMP로 옮겨간다. 생산자 쪽은 qfile_add_tuple_to_list로 적고, 소비자 쪽은 qfile_open_list_scan + qfile_scan_list_next로 읽는다. 디스크 위 페이지 모양은 PAGE_QRESULT이고, 32바이트짜리 QFILE_PAGE_HEADER가 머리에 붙는다. 튜플은 길이 접두 형식의 팩 행이며, 튜플마다 길이 하나, 값마다 길이 하나가 따로 붙는다. list-file은 복구 대상도 아니고 WAL 로그 대상도 아니다. 그 안에 든 내용에 대고 커밋한 트랜잭션이 없으니 되돌릴 일이 없고, 크래시가 나면 그냥 버리면 된다.
우리 BUILDLIST_PROC XASL에서 행 단위 생산 호출은 qexec_end_one_iteration이다. 안쪽 Volcano 루프 안에서 술어가 V_TRUE를 돌려주고 MVCC가 그 행을 보이는 것으로 표시하면, 실행기는 컬럼들을 출력 VAL_LIST로 모아 QFILE_TUPLE로 묶고 xasl->list_id에 대고 qfile_add_tuple_to_list를 부른다. list-file은 인메모리 membuf 배열 안에서 들어갈 수 있는 만큼은 거기에 머문다. 임계값을 넘기면 file_manager.c의 file_create_temp를 거쳐 디스크 위의 FILE_TEMP로 옮겨가는데, 이 옮겨감은 생산자에게도 소비자에게도 보이지 않는다.
안쪽 루프가 마르면(접근 방법이 S_END를 돌려주면), list-file은 쓰기를 닫고(qfile_close_list) 결과를 보낼 채비를 마친다. 작은 SELECT라면 별도의 후행 발행 단계를 두지 않고, 만들어 내는 동안 와이어로 그대로 흘려 보내는 일도 있다. 어느 쪽이든 최종 list-file이 표준 인계 지점이다. 클라이언트 쪽이 다룰 커서 객체가 그 list-file 위에 얹힌다. cubrid-cursor.md는 CURSOR_ID가 클라이언트 쪽 페치 손잡이라고 풀어 준다. 그것이 서버 쪽 QFILE_LIST_ID에 자기를 걸고, qfile_get_list_file_page 왕복 호출로 한 번에 네트워크 페이지 한 장씩 튜플을 끌어온다. 보유 가능 커서(holdable cursor, COMMIT을 넘어 살아남는 쪽)는 트랜잭션에서 떼어 내어 세션의 holdable_cursors 목록에 다시 붙인다. 그 메커니즘은 cubrid-cursor.md가 풀어 둔다. 보유 가능하지 않은 커서는 COMMIT/ROLLBACK 시점에 사라진다.
11단계 — 결과가 클라이언트로 돌아간다
섹션 제목: “11단계 — 결과가 클라이언트로 돌아간다”돌아오는 길은 들어갔던 길을 거꾸로 밟는다. cubrid-network-protocol.md는 서버에서 클라이언트로 가는 패킹을 짚어 준다. 결과 행마다 or_pack_value를 컬럼별로 한 번씩 부르고(컬럼당 호출 한 번), 그렇게 채워진 와이어 버퍼를 CSS 프레임으로 감싼 뒤 css_send_data_packet_for_request로 연결 소켓 위로 흘려 보낸다. SELECT의 응답 모양은 결과 집합 디스크립터(컬럼 타입, nullable 여부, 길이 목록)에 N개의 행이 뒤따르는 형태다. 디스크립터는 XASL의 outptr_list와 list-file의 QFILE_TUPLE_VALUE_TYPE_LIST에서 만들어 낸다. 서버 쪽 소켓의 반대편에 앉은 CAS 프로세스는 대칭 짝 or_unpack_*을 돌려 행을 클라이언트 쪽 DB_VALUE 자리로 풀어낸다. 그 뒤 그것을 다시 JDBC 쪽 소켓 위의 CCI 와이어 모양으로 다시 싸서 보낸다.
cubrid-broker.md가 짚어 두는 것은 브로커가 이 핫 경로 위에 있지 않다는 사실이다. 1단계에서 SCM_RIGHTS fd 핸드오프가 일어났기 때문에, JDBC 클라이언트와 CAS는 직거래로 통신한다. 브로커가 보고 싶다 한들 결과 행 하나를 직접 들여다볼 길이 없다. 브로커가 데이터 평면에 닿는 길은 간접 신호뿐이다. CAS가 공유 메모리에 적는 SQL 로그, CAS별 T_APPL_SERVER_INFO에서 늘어 가는 모니터 카운터, CAS의 idle/busy 상태 같은 것들이다. 그래서 CUBRID는 살아 있는 질의를 끊지 않고도 cub_broker를 디버깅하거나 다시 띄울 수 있다(원칙적으로는. 세부 문서가 모든 경로가 재시작 시에도 이 성질을 정말로 지키는지에 대한 미해결 의문 하나를 남겨 두었다).
JDBC 드라이버는 행 흐름을 받아 애플리케이션에 ResultSet으로 내놓는다. rs.next() 한 번이 드라이버 메모리에 이미 들어와 있는 행을 그대로 돌려주거나, 커서 프로토콜로 CAS에서 다음 네트워크 페이지를 끌어온다. 애플리케이션이 결과 집합을 닫거나 트랜잭션을 커밋하는 그 순간, 커서가 닫히거나(또는 보유 가능 목록으로 옮겨간다), qfile_destroy_list가 list-file의 임시 페이지를 거두고, CAS는 T_SRV_HANDLE을 풀어 주고, 더 이상 prepared 상태로 남은 구문이 없으면 DB_SESSION도 정리된다. 그러다 결국 드라이버 쪽에서 Connection.close()가 떨어지면 JDBC TCP 소켓이 닫힌다. 그러면 CAS는 브로커의 idle 풀에 다시 자리를 잡거나(다음 일거리를 기다리며), KEEP_CONNECTION = AUTO이고 time_to_kill이 다 되었다면 거두어지고 그 자리에 새 CAS가 fork되어 들어선다.
다이어그램 — 전체 파이프라인
섹션 제목: “다이어그램 — 전체 파이프라인”flowchart TD JDBC["JDBC 클라이언트<br/>Statement.executeQuery"] Driver["JDBC 드라이버 / CCI 네이티브"] Broker["cub_broker<br/>(receiver_thr_f → dispatch_thr_f<br/>find_idle_cas)"] CAS["cub_cas 워커<br/>(ux_database_connect /<br/>ux_prepare / ux_execute)"] DBI["클라이언트 db_∗ API<br/>db_open_buffer →<br/>db_compile_statement_local →<br/>db_execute_statement_local"] NetCl["network_cl<br/>or_pack_∗<br/>NET_SERVER_QM_QUERY_PREPARE"] Master["cub_master + connector<br/>AF_UNIX 위에서"] Worker["cubconn::connection::worker<br/>(epoll 루프)"] Dispatch["net_Requests[]<br/>디스패치 테이블"] Session["xsession_check_session<br/>SESSION_STATE 묶기"] TDES["LOG_FIND_THREAD_TRAN_INDEX<br/>LOG_TDES 묶기"] Lex["Flex csql_lexer.l<br/>yylex()"] Bison["Bison %glr-parser<br/>csql_grammar.y<br/>parser_new_node"] PT["PT_NODE 트리"] Sem["pt_check_with_info<br/>이름 해석 → 타입 검사<br/>→ 상수 폴딩 → pt_cnf"] Rew["mq_rewrite<br/>(query_rewrite.c)"] Opt["qo_optimize_query<br/>QO_NODE/QO_TERM/QO_SEGMENT<br/>DP 조인 열거"] Stats["xstats_update_statistics<br/>· qo_iscan/sscan_cost"] Gen["xasl_generation.c<br/>gen_outer/gen_inner<br/>· xts_∗ 직렬화기"] Cache["XASL 캐시<br/>SHA-1 hash text<br/>xcache_remove_by_oid"] Exec["qexec_execute_mainblock_internal<br/>switch (xasl->type)<br/>BUILDLIST_PROC → qexec_intprt_fnc"] Scan["scan_open_<type>_scan<br/>scan_next_scan<br/>switch (SCAN_TYPE)"] Heap["heap_next / heap_next_record<br/>HEAP_CHAIN 순회<br/>heap_attrinfo_read_dbvalues"] BTree["btree_keyval_search<br/>리프 형제 사슬 따라가기<br/>(key||OID)"] PGB["pgbuf_fix BCB<br/>세 구역 LRU"] Eval["eval_pred PRED_EXPR 순회<br/>fetch_peek_dbval<br/>eval_fnc 모양 특수화"] Sclr["qdata_∗_dbval<br/>tp_value_auto_cast<br/>타입 짝 비교기"] MVCC["mvcc_satisfies_snapshot<br/>활성 MVCCID 집합 확인<br/>버전 사슬 따라가기"] LF["qfile_add_tuple_to_list<br/>QFILE_LIST_ID<br/>membuf → FILE_TEMP"] Cur["CURSOR_ID<br/>qfile_get_list_file_page"] NetSr["or_pack_value 행들<br/>CSS 프레임 응답"] CASBack["CAS 서버 쪽 소켓<br/>or_unpack_value"] CASOut["CAS JDBC 쪽 소켓<br/>(SCM_RIGHTS 핸드오프 받은 자리)"] Result["JDBC ResultSet<br/>애플리케이션 코드"] JDBC --> Driver Driver -. TCP .-> Broker Broker -. SCM_RIGHTS fd .-> CAS CAS --> DBI DBI --> NetCl NetCl -. CSS 프레임 .-> Master Master --> Worker Worker --> Dispatch Dispatch --> Session Session --> TDES TDES --> Lex Lex --> Bison Bison --> PT PT --> Sem Sem --> Rew Rew --> Opt Stats -. 흘러든다 .-> Opt Opt --> Gen Gen --> Cache Cache --> Exec Exec --> Scan Scan --> Heap Scan --> BTree Heap --> PGB BTree --> PGB PGB --> Eval Eval --> Sclr Sclr --> MVCC MVCC -- 보임 --> LF MVCC -- 건너뜀 --> Scan LF --> Cur Cur --> NetSr NetSr -. CSS 프레임 .-> CASBack CASBack --> CASOut CASOut -. TCP .-> Driver Driver --> Result classDef detail fill:#eef,stroke:#557,stroke-width:1px; class Broker,CAS detail; class DBI detail; class NetCl,Master,Worker,Dispatch detail; class Session,TDES detail; class Lex,Bison,PT,Sem,Rew detail; class Opt,Stats,Gen,Cache detail; class Exec,Scan detail; class Heap,BTree,PGB detail; class Eval,Sclr,MVCC detail; class LF,Cur,NetSr detail;
주요 화살표마다 어떤 세부 문서가 그 메커니즘을 다루는지를 짚어 둔다.
JDBC → Broker → CAS—cubrid-broker.md(TCP 리스너, 잡 큐 디스패치,SCM_RIGHTSfd 핸드오프) +cubrid-dbi-cci.md(CCI 와이어,T_SRV_HANDLE,ux_database_connect).CAS → DBI → NetCl → Master → Worker → Dispatch—cubrid-network-protocol.md(CSS 프레임,NET_SERVER_*옵코드 디스패치,net_Requests[]테이블) +cubrid-dbi-cci.md(db_*API 표면, 4단계 구문 FSM).Dispatch → Session → TDES—cubrid-server-session.md(SESSION_STATElatch-free 해시,CSS_CONN_ENTRY캐시) +cubrid-transaction.md(LOG_FIND_THREAD_TRAN_INDEX을 거친 TDES 묶기).Lex → Bison → PT → Sem → Rew—cubrid-parser.md(Flex/Bison GLR,PT_NODE트리, 컨텍스트별 블록 할당기),cubrid-semantic-check.md(이름 해석, 타입 검사, 상수 폴딩, CNF),cubrid-query-rewrite.md(mq_rewrite, 변환 목록).Opt + Stats → Gen → Cache—cubrid-query-optimizer.md(QO_ENV 그래프, DP 조인 열거, 비용 모델),cubrid-statistics.md(카디널리티/NDV/페이지 수),cubrid-xasl-generator.md(gen_outer/gen_inner, REGU_VARIABLE/ACCESS_SPEC/OUTPTR_LIST,xts_*직렬화기),cubrid-xasl-cache.md(SHA-1 hash text, RT 재컴파일, 클래스별 OID 무효화).Cache → Exec → Scan—cubrid-query-executor.md(qexec_execute_mainblock_internal,qexec_intprt_fnc, 세 겹 Volcano 루프) +cubrid-scan-manager.md(SCAN_ID 디스패치, open/start/next/end/close 프로토콜).Scan → Heap/BTree → PGB—cubrid-heap-manager.md(슬롯 페이지, OID =(file, page, slot),heap_next,record_type디스패치, MVCC 버전 사슬의 닻),cubrid-btree.md(key||OID, latch-coupling, 리프 형제 따라가기),cubrid-page-buffer-manager.md(BCB, 세 구역 LRU, page fix/unfix 프로토콜).PGB → Eval → Sclr → MVCC—cubrid-query-evaluator.md(eval_pred의 PRED_EXPR 순회,fetch_peek_dbval, 세 값 논리,eval_fnc특수화),cubrid-scalar-functions.md(qdata_*_dbval, 타입 짝 비교기, NULL 전파),cubrid-mvcc.md(mvcc_satisfies_snapshot, 활성 MVCCID 집합, 버전 사슬 따라가기).MVCC → LF → Cur → NetSr → CAS → JDBC—cubrid-list-file.md(QFILE_LIST_ID,qfile_add_tuple_to_list, membuf에서 temp로의 옮겨감),cubrid-cursor.md(CURSOR_ID, 보유 가능 인계, 페이지 단위 페치),cubrid-network-protocol.md(or_pack_value, CSS 응답 프레임) +cubrid-broker.md와cubrid-dbi-cci.md(나가는 CAS 짝쪽 홉).
다루지 않은 내용
섹션 제목: “다루지 않은 내용”예시 질의는 일부러 작게 잡아 두었다. 실행기와 그 둘레 모듈에는 이 경로 위에 없는 큰 가지들이 여러 있다. 다른 질의 모양을 좇다가 이 문서로 들어온 독자가 길을 잡을 수 있도록, 한 줄짜리 길잡이를 모아 둔다.
- 후처리(group-by, order-by, distinct, window/analytic). XASL의
qexec_groupby/qexec_orderby_distinct/qexec_execute_analytic단계가 주 풀링 루프가 마른 뒤에 돌면서 켜진다. 두 번째 패스 기계 전체는cubrid-post-processing.md를 참고하라. - 해시 조인(빌드/탐사). 옵티마이저가 조인 잎에
HASH_LIST_SCAN을 잡았을 때 켜진다. 빌드 쪽은 list 파일에서 튜플을 읽어 해시하고, 탐사 쪽은 바깥 루프 튜플마다 해시 조회를 던진다.cubrid-hash-join.md를 참고하라. - 파티션 프루닝. 질의 대상 테이블이 파티션되어 있다면, 옵티마이저가 계획을 만들기 전에 관련 없는 파티션을 잘라 낸다. 실행기는 그 뒤 살아남은 파티션들을 스캔 블록 루프 안에서 하나씩 돈다.
cubrid-partition.md를 참고하라. - 병렬 질의.
S_PARALLEL_HEAP_SCAN이 잡히면 heap이 병렬 스캔 매니저의 조율 아래 워커 스레드들에 갈라져 흐른다.cubrid-parallel-query.md를 참고하라. - JSON_TABLE. JSON 문서 컬럼에서 끌어낸 가상 관계다. SCAN_ID union의
S_JSON_TABLE_SCAN팔과cubscan::json_table::scannerC++ 객체를 쓴다.cubrid-json-table.md를 참고하라. - dblink(외부 데이터).
S_DBLINK_SCAN과 원격 CCI 드라이버를 거쳐 도는 교차 DB 질의. dblink 쪽은cubrid-scan-manager.md의DBLINK_SCAN_ID절을 참고하라. - DML proc 종류(INSERT / UPDATE / DELETE / MERGE).
qexec_intprt_fnc가 아니라qexec_execute_insert/_update/_delete/_merge가 받는다. 더 넓은 DML 이야기는cubrid-ddl-execution.md를 참고하라. - 트리거와 권한 검사. SELECT도
BEFORE/AFTER STATEMENT트리거를 깨울 수 있다(여기서는 없다). 권한 검사는net_Requests[]의CHECK_AUTHORIZATION속성으로 파서 진입 전에 걸린다.cubrid-trigger.md와cubrid-authentication.md를 참고하라. - 락. 평범한 MVCC 스냅샷 SELECT는 행 락을 잡지 않는다.
SELECT ... FOR UPDATE는mvcc_select_lock_needed를 켜고cubrid-lock-manager.md로 흘러간다. - 복구 / WAL. SELECT는 로그 레코드를 적지 않는다. 페이지 버퍼의 WAL 순서 제약(
cubrid-log-manager.md,cubrid-recovery-manager.md)은 쓰기 위에서만 켜진다. - 복제 / HA. 대기 서버는 로그 레코드를 적용하지만 SELECT 파이프라인은 돌리지 않는다. HA가 잡혀 있으면
cub_master기계가 같은 길로 연결을 마스터로 흘려보낸다(cubrid-ha-replication.md,cubrid-heartbeat.md). - Vacuum / 버전 사슬 거두기. MVCC 독자가 전역 가장 오래된 가시 워터마크를 잡아 둔다. vacuum은 별개 프로세스로 돈다.
cubrid-vacuum.md를 참고하라.
이 문서는 종합이다. 각 단계에서 짚은 세부 문서들이 진짜 메커니즘을 풀어 두는 곳이고, 본문에 나오는 심볼 이름들도 그 문서의 ## Source Walkthrough와 ## CUBRID's Approach 절에서 가져온 것이지 소스 트리를 새로 읽은 결과는 아니다. CUBRID 소스 트리 자체는 /data/hgryoo/references/cubrid/에 있고, 이 여행이 함의하는 진입점 파일들은 다음과 같다.
src/broker/broker.c,src/broker/cas.c,src/broker/cas_execute.c— 브로커와 CAS(1단계, 11단계).src/compat/db_admin.c,src/compat/db_vdb.c,src/compat/db_query.c—db_*클라이언트 API(1단계).src/communication/network_cl.c,src/communication/network_sr.c,src/communication/network_interface_*.{c,cpp}— NRP 와이어(2단계, 11단계).src/connection/connection_sr.c,src/connection/server_support.c— CSS 서버 쪽 프레임(2단계).src/session/session.c,src/session/session_sr.c—SESSION_STATE(2단계).src/transaction/log_tran_table.c,src/transaction/transaction_sr.c— TDES(2단계).src/parser/csql_lexer.l,src/parser/csql_grammar.y,src/parser/parse_tree.h,src/parser/parse_tree_cl.c— 파서(3단계).src/parser/semantic_check.c,src/parser/name_resolution.c,src/parser/type_checking.c,src/parser/cnf.c— 의미 검사(3단계).src/optimizer/rewriter/query_rewrite*.c,src/parser/compile.c— 질의 재작성(3단계).src/optimizer/query_graph.c,src/optimizer/query_planner.c,src/optimizer/plan_generation.c— 옵티마이저(4단계).src/storage/statistics_sr.c,src/storage/statistics_cl.c— 통계(4단계).src/parser/xasl_generation.c,src/query/xasl_to_stream.c,src/xasl/xasl_stream.cpp— XASL 생성기(4단계).src/query/xasl_cache.c,src/query/query_manager.c— XASL 캐시(4단계).src/query/query_executor.c— 실행기(5단계).src/query/scan_manager.c,src/query/scan_manager.h— 스캔 매니저(6단계).src/storage/heap_file.c,src/storage/slotted_page.c— heap 매니저(7단계).src/storage/btree.c,src/storage/btree_load.c— B+Tree(7단계).src/storage/page_buffer.c— 페이지 버퍼(7단계).src/query/query_evaluator.c,src/query/fetch.c,src/query/regu_var.cpp— 술어 평가기(8단계).src/query/arithmetic.c,src/query/numeric_opfunc.c,src/query/string_opfunc.c,src/query/query_opfunc.c— 스칼라 함수(8단계).src/transaction/mvcc.c,src/transaction/mvcc_table.cpp,src/transaction/mvcc_active_tran.cpp— MVCC(9단계).src/query/list_file.c,src/query/query_list.h— list file(10단계).src/query/cursor.c— 커서(10단계, 11단계).
본문에 짚은 세부 문서 전체 목록은 이 파일 머리의 references: 블록에 있다. ## 다이어그램 — 전체 파이프라인 아래의 화살표별 주석에서 단계별로 묶어 다시 한 번 호명해 두었다. 이 여행이 거치지 않는 가지들은 ## 다루지 않은 내용 절에 추가 문서 길잡이와 함께 모아 두었다.