통계와 매개 변수 스니핑
계획이 어긋나는 까닭과 OPTION RECOMPILE 을 봅니다.
어림값은 통계에서 옵니다
5.1 에서 계획마다 붙어 있던 어림한 행 수를 보았습니다. 엔진은 표를 세어 보고 그 값을 내는 것이 아닙니다. 미리 만들어 둔 요약을 봅니다. 그 요약이 통계입니다.
통계는 두 가지를 담습니다.
| 담는 것 | 무엇 | 어디에 쓰입니까 |
|---|---|---|
| 히스토그램 | 값의 분포를 최대 200구간으로 나눈 것 | = 3 처럼 값이 정해진 조건 |
| 밀도 | 서로 다른 값이 몇 가지인지 | 값을 모를 때(변수·조인) |
-- 이 표에 통계가 무엇이 있는지 봅니다. SELECT s.name AS 통계, s.auto_created AS 자동생성, sp.rows AS 통계가아는행수, sp.steps AS 구간, sp.modification_counter AS 바뀐행 FROM sys.stats s CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) sp WHERE s.object_id = OBJECT_ID('Board.POSTS_BIG') ORDER BY s.stats_id;
| 통계 | 열 | 자동생성 | 아는 행수 | 구간 |
|---|---|---|---|---|
| PK_POSTS_BIG | num | 0 | NULL | NULL |
| IX_POSTS_BIG_list | board_num, group_num, sort_no | 0 | 500,000 | 3 |
| IX_POSTS_BIG_user | user_num | 0 | 500,000 | 20 |
| _WA_Sys_00000009_… | title | 1 | 500,000 | 175 |
인덱스 통계는 인덱스를 만들 때 함께 생깁니다. 인덱스가 없는
열이라도 조건에 사용하면 엔진이 그 자리에서 통계를 만듭니다
(_WA_Sys_).
PK_POSTS_BIG 이 NULL 인 것은
표가 비어 있을 때 만들어진 뒤 아직 한 번도 채워지지 않았기
때문입니다. num 으로 한 번 찾아 보면 바로
채워집니다.
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE num <= 100 OPTION (RECOMPILE);
| 통계 | 아는 행수 | 구간 |
|---|---|---|
| PK_POSTS_BIG | 500,000 | 120 |
히스토그램을 직접 봅니다
DBCC SHOW_STATISTICS('Board.POSTS_BIG', 'IX_POSTS_BIG_user') WITH HISTOGRAM; DBCC SHOW_STATISTICS('Board.POSTS_BIG', 'IX_POSTS_BIG_user') WITH DENSITY_VECTOR;
| RANGE_HI_KEY | RANGE_ROWS | EQ_ROWS | DISTINCT_RANGE_ROWS |
|---|---|---|---|
| 1 | 0.0 | 25,000.0 | 0 |
| 2 | 0.0 | 25,000.0 | 0 |
| 3 | 0.0 | 25,000.0 | 0 |
| 20 | 0.0 | 25,000.0 | 0 |
| All density | Average Length | Columns |
|---|---|---|
| 0.05 | 4.0 | user_num |
| 0.000002 | 8.0 | user_num, num |
| 열 | 뜻 |
|---|---|
| RANGE_HI_KEY | 이 구간의 마지막 값 |
| EQ_ROWS | 그 값과 정확히 같은 행이 몇 개인지 |
| RANGE_ROWS | 앞 구간과 이 값 사이에 몇 행이 있는지 |
| DISTINCT_RANGE_ROWS | 그 사이에 서로 다른 값이 몇 가지인지 |
| All density | 1 을 값의 가짓수로 나눈 것 |
WHERE user_num = 7 의 어림 25,000
은 히스토그램의 EQ_ROWS 를 그대로 읽은 값입니다.
구간이 200개뿐이므로 값이 수천 가지인 열은 여러 값이 한 구간에
묶입니다. 그때는 AVG_RANGE_ROWS 로
나눠 어림하므로 값마다 다르게 나오지 않습니다.
5.1 에서 지역 변수를 사용했을 때 어림이 150,000 이 된 것은 히스토그램을 아예 보지 못했기 때문입니다. 컴파일할 때 값을 모르면 히스토그램에서 찾을 자리가 없습니다.
통계가 낡으면 어림이 무너집니다
통계는 만든 순간의 사진입니다. 표가 바뀌어도 저절로 따라오지 않습니다. 회원 7의 글을 10만 건 넣어 보겠습니다.
-- 자동 갱신을 잠시 꺼서 낡은 상태를 만듭니다. ALTER DATABASE MssqlLab SET AUTO_UPDATE_STATISTICS OFF; GO INSERT INTO Board.POSTS_BIG (board_num, category_num, user_num, parent_num, group_num, depth, sort_no, title, content, hit_count, reg_date) SELECT TOP (100000) board_num, category_num, 7, NULL, group_num, depth, sort_no, title, content, hit_count, reg_date FROM Board.POSTS_BIG ORDER BY num; GO SET STATISTICS PROFILE ON; SELECT COUNT(*) FROM Board.POSTS_BIG WHERE user_num = 7; SET STATISTICS PROFILE OFF;
| 통계가 아는 행수 | 실제 행수 | 회원 7 | |
|---|---|---|---|
| 넣기 전 | 500,000 | 500,000 | 25,000 |
| 넣은 뒤 | 500,000 | 600,000 | 125,000 |
| 어림 | 실제 | |
|---|---|---|
| 낡은 통계로 | 30,000 | 125,000 |
| UPDATE STATISTICS 뒤 | 125,428 | 125,000 |
어림이 넷째 배 작으면 계획이 잘못 잡힙니다. 3만 행인 줄 알고 세운 계획으로 12만 5천 행을 처리하게 되고, 그 문장이 조인이나 정렬을 안고 있으면 메모리도 3만 행 몫만 잡습니다.
-- 하나만 다시 만듭니다. UPDATE STATISTICS Board.POSTS_BIG IX_POSTS_BIG_user; -- 표의 통계를 모두 다시 만듭니다. UPDATE STATISTICS Board.POSTS_BIG; -- 표본이 아니라 전부 읽어 만듭니다. 정확하지만 오래 걸립니다. UPDATE STATISTICS Board.POSTS_BIG WITH FULLSCAN; -- 데이터베이스 전체를 훑어 낡은 것만 다시 만듭니다. EXEC sp_updatestats;
자동 갱신은 언제 일어납니까
평소에는 엔진이 알아서 다시 만듭니다. 다만 그 시점이 중요합니다.
ALTER DATABASE MssqlLab SET AUTO_UPDATE_STATISTICS ON; GO -- 3만 행을 더 넣습니다. 임계(약 2만 4천)를 넘습니다. -- 그리고 같은 문장을 다시 조회합니다. SELECT COUNT(*) FROM Board.POSTS_BIG WHERE user_num = 8; GO -- 이번에는 계획을 새로 만들게 합니다. SELECT COUNT(*) FROM Board.POSTS_BIG WHERE user_num = 8 OPTION (RECOMPILE);
| 통계가 아는 행수 | 바뀐 행 | 갱신 시각 | |
|---|---|---|---|
| 3만 행을 넣고 그냥 조회 | 600,000 | 30,000 | 그대로 |
| RECOMPILE 로 조회 | 630,000 | 0 | 바뀌었습니다 |
자동 갱신은 계획을 새로 만들 때 확인합니다. 캐시에 있는 계획을 그대로 사용하는 동안에는 통계가 낡았는지 아무도 보지 않습니다. 자주 도는 문장일수록 계획이 오래 남으므로, 대량으로 넣거나 지운 뒤에는 손으로 갱신해 주는 편이 안전합니다.
임계는 대략 √(1000 × 행수) 입니다. 60만 행이면 약 2만 4천
행입니다. 표가 커질수록 비율로는 작아지므로 큰 표일수록 자동 갱신에
기대기 어렵습니다. 야간 작업으로
UPDATE STATISTICS 를 도는 곳이 많은 것도 이
때문입니다.
먼저 부른 쪽이 계획을 정합니다
프로시저는 처음 실행될 때 계획을 만들어 두고 다음부터 그것을 재사용합니다(4.2). 이때 엔진은 그 첫 호출에 들어온 매개 변수 값을 보고 계획을 세웁니다. 이것을 매개 변수 스니핑이라고 합니다.
대개는 도움이 됩니다. 실제 값을 보고 히스토그램을 찾으니 어림이 정확해집니다. 문제는 그다음 호출이 아주 다른 값을 가지고 올 때입니다.
CREATE OR ALTER PROCEDURE Board.P_UPTO @n int AS BEGIN SET NOCOUNT ON; -- 화면에 40만 행을 뿌리지 않으려고 변수에 담습니다. 읽는 양은 같습니다. DECLARE @s nvarchar(200); SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @n; END GO -- 캐시를 비우고 작은 값부터 부릅니다. DBCC FREEPROCCACHE; GO SET STATISTICS IO ON; EXEC Board.P_UPTO @n = 100; EXEC Board.P_UPTO @n = 400000;
| 먼저 부른 값 | @n = 100 | @n = 400000 |
|---|---|---|
| 100 을 먼저 | 5 | 5,858 |
| 400000 을 먼저 | 4,535 | 4,535 |
| 각각의 최적값 | 5 | 4,535 |
두 계획이 무엇인지 보면 까닭이 분명합니다.
SET SHOWPLAN_TEXT ON; GO DECLARE @s nvarchar(200); SELECT @s = title FROM Board.POSTS_BIG WHERE num <= 100; GO DECLARE @s nvarchar(200); SELECT @s = title FROM Board.POSTS_BIG WHERE num <= 400000; GO SET SHOWPLAN_TEXT OFF;
둘 다 자기 값에는 맞는 계획입니다. 100건이면 표 앞부분
다섯 장만 읽으면 되고, 40만 건이면 표(7,305장)보다
IX_POSTS_BIG_list(4,519장)를 통째로 읽는 편이
쌉니다. 어긋난 값에 재사용되는 것이 문제입니다.
증상이 "어제까지 잘 되던 것이 오늘 갑자기 느려짐" 으로 나타납니다. 서버를 다시 시작했거나, 통계가 갱신되어 계획이 새로 만들어졌거나, 캐시에서 밀려났을 때 그 순간 누가 어떤 값으로 불렀느냐에 따라 계획이 달라지기 때문입니다. 코드는 그대로인데 속도만 바뀝니다.
네 가지 방법이 있습니다
-- 아래는 P_UPTO 안의 SELECT 를 바꿔 적은 것입니다. -- 그대로 실행하지 말고 프로시저 본문에 넣어 견주십시오. -- (1) 문장마다 새로 컴파일합니다. SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @n OPTION (RECOMPILE); -- (2) 정해 둔 값으로 계획을 만듭니다. SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @n OPTION (OPTIMIZE FOR (@n = 400000)); -- (3) 값을 안 본 것으로 치고 평균으로 만듭니다. SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @n OPTION (OPTIMIZE FOR UNKNOWN); -- (4) 지역 변수에 옮겨 담습니다. (3)과 같은 결과가 됩니다. DECLARE @local int = @n; SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @local;
| 방법 | @n = 100 | @n = 400000 |
|---|---|---|
| 그냥 두면(100 을 먼저 부른 경우) | 5 | 5,858 |
| (1) RECOMPILE | 5 | 4,535 |
| (3) OPTIMIZE FOR UNKNOWN | 5 | 5,858 |
| (4) 지역 변수 | 5 | 5,858 |
| 각각의 최적값 | 5 | 4,535 |
RECOMPILE 은 값을 치릅니다. 매번 계획을 새로 만들기 때문입니다.
-- 같은 프로시저를 1,000번씩 부릅니다. DECLARE @i int = 1, @t datetime2(7) = SYSDATETIME(); WHILE @i <= 1000 BEGIN EXEC Board.P_UPTO @n = 100; SET @i += 1; END PRINT CONCAT(DATEDIFF(millisecond, @t, SYSDATETIME()), ' ms');
| 걸린 시간 | |
|---|---|
| 계획을 재사용 | 30~32 밀리초 |
| OPTION (RECOMPILE) | 354~360 밀리초 |
| 방법 | 언제 씁니까 | 대가 |
|---|---|---|
| RECOMPILE | 값에 따라 건수가 크게 달라질 때 | 호출마다 컴파일 |
| OPTIMIZE FOR (값) | 흔한 값을 알고 있을 때 | 그 값이 바뀌면 다시 손대야 합니다 |
| OPTIMIZE FOR UNKNOWN | 어느 쪽으로도 치우치지 않게 | 양쪽 다 최적은 아닙니다 |
| 지역 변수 | (권하지 않습니다) | 의도가 코드에 드러나지 않습니다 |
지역 변수로 옮겨 담는 것은 문법이 아니라 부작용입니다.
읽는 사람은 왜 옮겨 담았는지 알 수 없습니다. 같은 효과를 원한다면
OPTION (OPTIMIZE FOR UNKNOWN) 을 적으십시오.
무엇을 의도했는지가 코드에 남습니다.
먼저 스니핑이 원인인지부터 확인하십시오. 같은 프로시저가 어떤 값에서만 느리다면 스니핑입니다. 어느 값에서나 느리다면 인덱스나 문장 자체의 문제이고(5.2 · 5.4), 그때 RECOMPILE 을 붙여 봐야 컴파일 비용만 늘어납니다.
직접 해보기
Board.POSTS_BIG 에서
통계가 아직 만들어지지 않은 열을 찾아 보세요. 그다음
그 열로 한 번 조회하고 다시 확인해 보십시오.
회원별 글 목록을 내는 프로시저입니다. 어떤 회원 번호로 먼저 부르느냐에 따라 뒤가 느려지는지 확인하고, 고칠 방법을 골라 근거를 적어 보세요.
CREATE OR ALTER PROCEDURE Board.P_USER_POSTS @user int AS BEGIN SET NOCOUNT ON; DECLARE @s nvarchar(200); SELECT @s = title FROM Board.POSTS_BIG WHERE user_num = @user; END
이 단원에서 만든 것을 지웁니다.
DROP PROCEDURE Board.P_UPTO, Board.P_USER_POSTS;
통계 실험으로 표가 63만 행이 되어 있습니다.
lab-bigdata.sql 을 다시 실행해
50만 행으로 되돌리십시오.
- 어림값은 통계에서 옵니다. 히스토그램은 값의 분포를 최대 200구간으로, 밀도는 값이 몇 가지인지를 담습니다.
- 인덱스를 만들면 통계가 함께 생기고, 인덱스가 없는 열도 조건에 사용하면 엔진이 스스로 만듭니다(
_WA_Sys_). WHERE user_num = 7의 어림 25,000 은 히스토그램의EQ_ROWS를 그대로 읽은 값입니다.- 통계는 만든 순간의 사진입니다. 10만 행을 넣고 갱신하지 않으면 어림이 30,000, 실제는 125,000 이 됩니다.
- 자동 갱신은 계획을 새로 만들 때 확인합니다. 캐시된 계획을 재사용하는 동안에는 임계를 넘겨도 갱신되지 않습니다. 대량 작업 뒤에는
UPDATE STATISTICS를 손으로 도십시오. - 매개 변수 스니핑은 첫 호출의 값으로 만든 계획을 그대로 재사용하는 것입니다. 100 을 먼저 부르면 40만이 5,858장, 400000 을 먼저 부르면 100건이 4,535장(최적 5장)입니다.
- 코드는 그대로인데 속도만 바뀌는 증상이면 스니핑을 의심하십시오. 계획이 새로 만들어지는 순간 누가 어떤 값으로 불렀느냐로 갈립니다.
- 고치는 방법은
RECOMPILE·OPTIMIZE FOR (값)·OPTIMIZE FOR UNKNOWN입니다. RECOMPILE 만 두 값 모두 최적이지만 1,000번에 30밀리초가 355밀리초가 됩니다. - 지역 변수로 옮겨 담는 것도 같은 효과를 내지만 의도가 코드에 남지 않으므로
OPTIMIZE FOR UNKNOWN을 적으십시오. - 값마다 다른지 언제나 느린지를 먼저 가리십시오. 언제나 느리면 스니핑이 아니라 인덱스나 문장의 문제입니다.