В предишна публикация в блога Моите любими PostgreSQL заявки и защо те са важни, посетих интересни заявки, които имат значение за мен, докато уча, развивам и прераствам в ролята на SQL разработчик.
Едно от тях, по-специално, многоредова актуализация с един израз CASE, предизвика интересен разговор в Hacker News.
В тази публикация в блога искам да наблюдавам сравнения между тази конкретна заявка и такава, включваща множество единични изрази UPDATE. За добро или зло.
Спецификации на машината/средата:
- CPU Intel(R) Core(TM) i5-6200U @ 2,30 GHz
- 8 GB RAM
- 1TB място за съхранение
- Xubuntu Linux 16.04.3 LTS (Xenial Xerus)
- PostgreSQL 10.4
Забележка:За начало създадох „поетапна“ таблица с всички колони от тип TEXT, за да се заредят данните.
Примерният набор от данни, който използвам, се намира на тази връзка тук.
Но имайте предвид, че самите данни се използват в този пример, защото е набор с приличен размер с множество колони. Всеки „анализ“ или АКТУАЛИЗИРАНЕ/ВМЪКВАНЕ към този набор от данни не отразява действителните „реални“ GPS/GIS операции и не е предназначен като такъв.
location=# \d data_staging;
Table "public.data_staging"
Column | Type | Collation | Nullable | Default
---------------+---------+-----------+----------+---------
segment_num | text | | |
point_seg_num | text | | |
latitude | text | | |
longitude | text | | |
nad_year_cd | text | | |
proj_code | text | | |
x_cord_loc | text | | |
y_cord_loc | text | | |
last_rev_date | text | | |
version_date | text | | |
asbuilt_flag | text | | |
location=# SELECT COUNT(*) FROM data_staging;
count
--------
546895
(1 row)
Имаме около половин милион реда данни в тази таблица.
За това първо сравнение ще АКТУАЛИЗИРАМ колоната на proj_code.
Ето проучвателна заявка за определяне на текущите й стойности:
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
"70"
""
"72"
"71"
"51"
"15"
"16"
(7 rows)
Ще използвам trim, за да премахна кавички от стойностите и ще прехвърля към INT и ще определя колко реда съществуват за всяка отделна стойност:
Нека използваме CTE за това, след което ИЗБЕРЕТЕ от него:
location=# WITH cleaned_nums AS (
SELECT NULLIF(trim(both '"' FROM proj_code), '') AS p_code FROM data_staging
)
SELECT COUNT(*),
CASE
WHEN p_code::int = 70 THEN '70'
WHEN p_code::int = 72 THEN '72'
WHEN p_code::int = 71 THEN '71'
WHEN p_code::int = 51 THEN '51'
WHEN p_code::int = 15 THEN '15'
WHEN p_code::int = 16 THEN '16'
ELSE '00'
END AS proj_code_num
FROM cleaned_nums
GROUP BY p_code
ORDER BY p_code DESC;
count | proj_code_num
--------+---------------
353087 | 0
139057 | 72
25460 | 71
3254 | 70
1 | 51
12648 | 16
13388 | 15
(7 rows)
Преди да стартирам тези тестове, ще продължа и ще ПРОМЕНЯ колоната proj_code, за да напиша INTEGER:
BEGIN;
ALTER TABLE data_staging ALTER COLUMN proj_code SET DATA TYPE INTEGER USING NULLIF(trim(both '"' FROM proj_code), '')::INTEGER;
SAVEPOINT my_save;
COMMIT;
И почистете тази стойност на колона NULL (която е представена от ELSE '00' в проучвателния CASE израз по-горе), като я зададете на произволно число, 10, с тази АКТУАЛИЗАЦИЯ:
UPDATE data_staging
SET proj_code = 10
WHERE proj_code IS NULL;
Сега всички колони на proj_code имат INTEGER стойност.
Нека да продължим и да изпълним единичен CASE израз, актуализиращ всички стойности на колоната proj_code и да видим какво отчита времето. Ще поставя всички команди в изходен файл .sql за по-лесно боравене.
Ето съдържанието на файла:
BEGIN;
\timing on
UPDATE data_staging
SET proj_code =
(
CASE proj_code
WHEN 72 THEN 7272
WHEN 71 THEN 7171
WHEN 15 THEN 1515
WHEN 51 THEN 5151
WHEN 70 THEN 7070
WHEN 10 THEN 1010
WHEN 16 THEN 1616
END
)
WHERE proj_code IN (72, 71, 15, 51, 70, 10, 16);
SAVEPOINT my_save;
Нека стартираме този файл и да проверим какво отчита времето:
location=# \i /case_insert.sql
BEGIN
Time: 0.265 ms
Timing is on.
UPDATE 546895
Time: 6779.596 ms (00:06.780)
SAVEPOINT
Time: 0.300 ms
Малко над половин милион реда за 6+ секунди.
Ето отразените промени в таблицата до момента:
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
7070
1616
1010
7171
1515
7272
5151
(7 rows)
Ще върна обратно (не е показано) тези промени, за да мога да изпълнявам отделни оператори INSERT, за да тествам и тях.
По-долу са представени модификациите на изходния файл .sql за тази серия от сравнения:
BEGIN;
\timing on
UPDATE data_staging
SET proj_code = 7222
WHERE proj_code = 72;
UPDATE data_staging
SET proj_code = 7171
WHERE proj_code = 71;
UPDATE data_staging
SET proj_code = 1515
WHERE proj_code = 15;
UPDATE data_staging
SET proj_code = 5151
WHERE proj_code = 51;
UPDATE data_staging
SET proj_code = 7070
WHERE proj_code = 70;
UPDATE data_staging
SET proj_code = 1010
WHERE proj_code = 10;
UPDATE data_staging
SET proj_code = 1616
WHERE proj_code = 16;
SAVEPOINT my_save;
И тези резултати,
location=# \i /case_insert.sql
BEGIN
Time: 0.264 ms
Timing is on.
UPDATE 139057
Time: 795.610 ms
UPDATE 25460
Time: 116.268 ms
UPDATE 13388
Time: 239.007 ms
UPDATE 1
Time: 72.699 ms
UPDATE 3254
Time: 162.199 ms
UPDATE 353087
Time: 1987.857 ms (00:01.988)
UPDATE 12648
Time: 321.223 ms
SAVEPOINT
Time: 0.108 ms
Нека проверим стойностите:
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
7222
1616
7070
1010
7171
1515
5151
(7 rows)
И времето (Забележка:ще направя изчисленията в заявка, тъй като \timing не отчита цели секунди това изпълнение):
location=# SELECT round((795.610 + 116.268 + 239.007 + 72.699 + 162.199 + 1987.857 + 321.223) / 1000, 3) AS seconds;
seconds
---------
3.695
(1 row)
Отделните INSERT са отнели около половината от времето, отколкото единичния CASE.
Този първи тест включва цялата таблица с всички колони. Любопитен съм за някакви разлики в таблица със същия брой редове, но по-малко колони, следователно следващата серия от тестове.
Ще създам таблица с 2 колони (съставена от тип данни SERIAL за ПЪРВИЧНИЯ КЛЮЧ и INTEGER за колоната на proj_code) и ще преместя данните:
location=# CREATE TABLE proj_nums(n_id SERIAL PRIMARY KEY, proj_code INTEGER);
CREATE TABLE
location=# INSERT INTO proj_nums(proj_code) SELECT proj_code FROM data_staging;
INSERT 0 546895
(За отбелязване:SQL командите от първия набор от операции се използват със съответните модификации. Тук ги пропускам за краткост и показване на екрана )
Първо ще изпълня единичния израз CASE:
location=# \i /case_insert.sql
BEGIN
Timing is on.
UPDATE 546895
Time: 4355.332 ms (00:04.355)
SAVEPOINT
Time: 0.137 ms
И след това отделните АКТУАЛИЗАЦИИ:
location=# \i /case_insert.sql
BEGIN
Time: 0.282 ms
Timing is on.
UPDATE 139057
Time: 1042.133 ms (00:01.042)
UPDATE 25460
Time: 123.337 ms
UPDATE 13388
Time: 212.698 ms
UPDATE 1
Time: 43.107 ms
UPDATE 3254
Time: 52.669 ms
UPDATE 353087
Time: 2787.295 ms (00:02.787)
UPDATE 12648
Time: 99.813 ms
SAVEPOINT
Time: 0.059 ms
location=# SELECT round((1042.133 + 123.337 + 212.698 + 43.107 + 52.669 + 2787.295 + 99.813) / 1000, 3) AS seconds;
seconds
---------
4.361
(1 row)
Времето е донякъде равномерно между двата набора операции в таблицата само с 2 колони.
Ще кажа, че използването на израза CASE е малко по-лесно за въвеждане, но не е непременно най-добрият избор при всички случаи. Както беше посочено в някои от коментарите в нишката на Hacker News, посочена по-горе, обикновено „просто зависи“ от много фактори, които могат или не могат да бъдат оптималният избор.
Осъзнавам, че тези тестове в най-добрия случай са субективни. Единият от тях е в таблица с 11 колони, докато другият имаше само 2 колони, като и двете бяха от числов тип данни.
Изразът CASE за актуализации на множество редове все още е една от любимите ми заявки, макар и само за лесното въвеждане в контролирана среда, където много индивидуални заявки UPDATE са другата алтернатива.
Сега обаче виждам къде това не винаги е оптималният избор, тъй като продължавам да се развивам и да се уча.
Както се казва в онази стара поговорка „Половин дузина в едната ръка, 6 в другата ."
Допълнителна любима заявка - Използване на PLpgSQL CURSOR's
Започнах да съхранявам и проследявам всички мои статистически данни за упражнения (трайл туризъм) с PostgreSQL на моята локална машина за разработка. Включени са множество таблици, както при всяка нормализирана база данни.
Въпреки това, в края на месеца искам да съхраня статистически данни за конкретни колони в собствена, отделна таблица.
Ето 'месечната' таблица, която ще използвам:
fitness=> \d hiking_month_total;
Table "public.hiking_month_total"
Column | Type | Collation | Nullable | Default
-----------------+------------------------+-----------+----------+---------
day_hiked | date | | |
calories_burned | numeric(4,1) | | |
miles | numeric(4,2) | | |
duration | time without time zone | | |
pace | numeric(2,1) | | |
trail_hiked | text | | |
shoes_worn | text | | |
Ще се концентрирам върху резултатите от май с тази SELECT заявка:
fitness=> SELECT hs.day_walked, hs.cal_burned, hs.miles_walked, hs.duration, hs.mph, tr.name, sb.name_brand
fitness-> FROM hiking_stats AS hs
fitness-> INNER JOIN hiking_trail AS ht
fitness-> ON hs.hike_id = ht.th_id
fitness-> INNER JOIN trail_route AS tr
fitness-> ON ht.tr_id = tr.trail_id
fitness-> INNER JOIN shoe_brand AS sb
fitness-> ON hs.shoe_id = sb.shoe_id
fitness-> WHERE extract(month FROM hs.day_walked) = 5
fitness-> ORDER BY hs.day_walked ASC;
И ето 3 примерни реда, върнати от тази заявка:
day_walked | cal_burned | miles_walked | duration | mph | name | name_brand
------------+------------+--------------+----------+-----+------------------------+---------------------------------------
2018-05-02 | 311.2 | 3.27 | 00:57:13 | 3.4 | Tree Trail-extended | New Balance Trail Runners-All Terrain
2018-05-03 | 320.8 | 3.38 | 00:58:59 | 3.4 | Sandy Trail-Drive | New Balance Trail Runners-All Terrain
2018-05-04 | 291.3 | 3.01 | 00:53:33 | 3.4 | House-Power Line Route | Keen Koven WP(keen-dry)
(3 rows)
Честно казано, мога да попълня целевата таблица hiking_month_total, използвайки горната заявка SELECT в оператор INSERT.
Но къде е забавното в това?
Ще се откажа от скуката за PLpgSQL функция с CURSOR вместо това.
Измислих тази функция, за да изпълня INSERT с КУРСОР:
CREATE OR REPLACE function monthly_total_stats()
RETURNS void
AS $month_stats$
DECLARE
v_day_walked date;
v_cal_burned numeric(4, 1);
v_miles_walked numeric(4, 2);
v_duration time without time zone;
v_mph numeric(2, 1);
v_name text;
v_name_brand text;
v_cur CURSOR for SELECT hs.day_walked, hs.cal_burned, hs.miles_walked, hs.duration, hs.mph, tr.name, sb.name_brand
FROM hiking_stats AS hs
INNER JOIN hiking_trail AS ht
ON hs.hike_id = ht.th_id
INNER JOIN trail_route AS tr
ON ht.tr_id = tr.trail_id
INNER JOIN shoe_brand AS sb
ON hs.shoe_id = sb.shoe_id
WHERE extract(month FROM hs.day_walked) = 5
ORDER BY hs.day_walked ASC;
BEGIN
OPEN v_cur;
<<get_stats>>
LOOP
FETCH v_cur INTO v_day_walked, v_cal_burned, v_miles_walked, v_duration, v_mph, v_name, v_name_brand;
EXIT WHEN NOT FOUND;
INSERT INTO hiking_month_total(day_hiked, calories_burned, miles,
duration, pace, trail_hiked, shoes_worn)
VALUES(v_day_walked, v_cal_burned, v_miles_walked, v_duration, v_mph, v_name, v_name_brand);
END LOOP get_stats;
CLOSE v_cur;
END;
$month_stats$ LANGUAGE PLpgSQL;
Нека извикаме функцията monthly_total_stats(), за да изпълним INSERT:
fitness=> SELECT monthly_total_stats();
monthly_total_stats
---------------------
(1 row)
Тъй като функцията е дефинирана RETURNS void, можем да видим, че не е върната стойност на повикващия.
Понастоящем не се интересувам конкретно от каквито и да било върнати стойности,
само че функцията изпълнява дефинираната операция, попълвайки таблицата hiking_month_total.
Ще поискам брой записи в целевата таблица, потвърждавайки, че има данни:
fitness=> SELECT COUNT(*) FROM hiking_month_total;
count
-------
25
(1 row)
Функцията monthly_total_stats() работи, но може би по-добрият случай на използване на CURSOR е да превъртате през голям брой записи. Може би таблица с около половин милион записи?
Следващият CURSOR е свързан със заявка, насочена към таблицата data_staging от поредицата от сравнения в раздела по-горе:
CREATE OR REPLACE FUNCTION location_curs()
RETURNS refcursor
AS $location$
DECLARE
v_cur refcursor;
BEGIN
OPEN v_cur for SELECT segment_num, latitude, longitude, proj_code, asbuilt_flag FROM data_staging;
RETURN v_cur;
END;
$location$ LANGUAGE PLpgSQL;
След това, за да използвате този КУРСОР, действайте в рамките на ТРАНЗАКЦИЯ (посочена в документацията тук).
location=# BEGIN;
BEGIN
location=# SELECT location_curs();
location_curs
--------------------
<unnamed portal 1>
(1 row)
И така, какво можете да направите с този "
Ето само няколко неща:
Можем да върнем първия ред от КУРСОРА, като използваме първо или АБСОЛЮТНО 1:
location=# FETCH first FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 3571" | " 29.0202942600" | " -90.2908612800" | 72 | "Y"
(1 row)
location=# FETCH ABSOLUTE 1 FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 3571" | " 29.0202942600" | " -90.2908612800" | 72 | "Y"
(1 row)
Искате ред почти по средата на набора от резултати? (Ако приемем, че знаем, че около половин милион реда са обвързани с CURSOR.)
Можете ли да бъдете толкова „конкретни“ с КЪРСОР?
Да.
Можем да позиционираме и извличаме стойностите за записа на ред 234888 (само произволно число, което избрах):
location=# FETCH ABSOLUTE 234888 FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159541400" | " -90.7778003500" | 10 | "Y"
(1 row)
Веднъж позициониран там, можем да преместим курсора „назад“:
location=# FETCH BACKWARD FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159358200" | " -90.7778242300" | 10 | "Y"
(1 row)
Което е същото като:
location=# FETCH ABSOLUTE 234887 FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159358200" | " -90.7778242300" | 10 | "Y"
(1 row)
След това можем да преместим курсора обратно към АБСОЛЮТНОТО 234888 с:
location=# FETCH FORWARD FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159541400" | " -90.7778003500" | 10 | "Y"
(1 row)
Удобен съвет:за да преместите КЪРСОРА, използвайте MOVE вместо FETCH, ако не се нуждаете от стойностите от този ред.
Вижте този пасаж от документацията:
"MOVE препозиционира курсора, без да извлича никакви данни. MOVE работи точно като командата FETCH, с изключение на това, че само позиционира курсора и не връща редове."
Името „
Ще прегледам отново данните си за фитнес статистиката, за да напиша функция и да наименувам CURSOR, заедно с потенциален случай на използване в „реален свят“.
CURSOR ще се насочи към тази допълнителна таблица, която съхранява резултати, не ограничени до месец май (по принцип всичко, което съм събрал досега), както в предишния пример:
fitness=> CREATE TABLE cp_hiking_total AS SELECT * FROM hiking_month_total WITH NO DATA;
CREATE TABLE AS
След това го попълнете с данни:
fitness=> INSERT INTO cp_hiking_total
SELECT hs.day_walked, hs.cal_burned, hs.miles_walked, hs.duration, hs.mph, tr.name, sb.name_brand
FROM hiking_stats AS hs
INNER JOIN hiking_trail AS ht
ON hs.hike_id = ht.th_id
INNER JOIN trail_route AS tr
ON ht.tr_id = tr.trail_id
INNER JOIN shoe_brand AS sb
ON hs.shoe_id = sb.shoe_id
ORDER BY hs.day_walked ASC;
INSERT 0 51
Сега с функцията PLpgSQL по-долу, СЪЗДАЙТЕ 'наименуван' КУРСОР:
CREATE OR REPLACE FUNCTION stats_cursor(refcursor)
RETURNS refcursor
AS $$
BEGIN
OPEN $1 FOR
SELECT *
FROM cp_hiking_total;
RETURN $1;
END;
$$ LANGUAGE plpgsql;
Ще нарека този CURSOR „статистика“:
fitness=> BEGIN;
BEGIN
fitness=> SELECT stats_cursor('stats');
stats_cursor
--------------
stats
(1 row)
Да предположим, че искам '12-тият' ред да бъде обвързан с CURSOR.
Мога да позиционирам КУРСОРА на този ред, като извличам тези резултати със следната команда:
fitness=> FETCH ABSOLUTE 12 FROM stats;
day_hiked | calories_burned | miles | duration | pace | trail_hiked | shoes_worn
------------+-----------------+-------+----------+------+---------------------+---------------------------------------
2018-05-02 | 311.2 | 3.27 | 00:57:13 | 3.4 | Tree Trail-extended | New Balance Trail Runners-All Terrain
(1 row)
За целите на тази публикация в блога си представете, че знам от първа ръка, че стойността на колоната за темп за този ред е неправилна.
По-конкретно си спомням, че този ден бях „уморен на краката си“ и поддържах само темп от 3,0 по време на това поход. (Хей, случва се.)
Добре, просто ще актуализирам таблицата cp_hiking_total, за да отразя тази промяна.
Сравнително просто, без съмнение. Скучно…
Какво ще кажете за статистическия CURSOR вместо това?
fitness=> UPDATE cp_hiking_total
fitness-> SET pace = 3.0
fitness-> WHERE CURRENT OF stats;
UPDATE 1
За да направите тази промяна постоянна, издайте COMMIT:
fitness=> COMMIT;
COMMIT
Нека да направим заявка и да видим, че UPDATE е отразено в таблица cp_hiking_total:
fitness=> SELECT * FROM cp_hiking_total
fitness-> WHERE day_hiked = '2018-05-02';
day_hiked | calories_burned | miles | duration | pace | trail_hiked | shoes_worn
------------+-----------------+-------+----------+------+---------------------+---------------------------------------
2018-05-02 | 311.2 | 3.27 | 00:57:13 | 3.0 | Tree Trail-extended | New Balance Trail Runners-All Terrain
(1 row)
Колко готино е това?
Преместете се в рамките на набора от резултати на CURSOR и изпълнете UPDATE, ако е необходимо.
Доста мощен, ако питате мен. И удобно.
Някои „внимание“ и информация от документацията за този тип КУРСОР:
"Обикновено се препоръчва да се използва FOR UPDATE, ако курсорът е предназначен да се използва с UPDATE ... WHERE CURRENT OF или DELETE ... WHERE CURRENT OF. Използването на FOR UPDATE предотвратява промяната на редовете на други сесии между времето те се извличат и времето, в което се актуализират. Без FOR UPDATE, последваща команда WHERE CURRENT OF няма да има ефект, ако редът е бил променен след създаването на курсора.
Друга причина да използвате FOR UPDATE е, че без него последващо WHERE CURRENT OF може да се провали, ако заявката на курсора не отговаря на правилата на SQL стандарта за „просто актуализиране“ (по-специално курсорът трябва да препраща само към една таблица и не използвайте групиране или ПОРЪЧАЙТЕ ПО). Курсорите, които не могат просто да се актуализират, може да работят или не, в зависимост от подробностите за избор на план; така че в най-лошия случай едно приложение може да работи при тестване и след това да се провали в производството."
С CURSOR, който използвах тук, следвах стандартните правила на SQL (от горните пасажи) в аспекта на:Позовах само една таблица, без групиране или клауза ORDER по.
Защо има значение.
Както при многобройните операции, заявки или задачи в PostgreSQL (и SQL като цяло), обикновено има повече от един начин да постигнете и постигнете крайната си цел. Което е една от основните причини да съм привлечен от SQL и да се стремя да науча повече.
Надявам се чрез тази последваща публикация в блога да дадох известна представа защо многоредовата актуализация с CASE беше включена като една от любимите ми заявки в тази първа придружаваща публикация в блога. Просто да го имам като опция си заслужава за мен.
В допълнение, проучване на CURSORS, за преминаване на големи набори от резултати. Извършването на DML операции, като АКТУАЛИЗИРАНЕ и/или ИЗТРИВАНЕ, с правилния тип КУРСОР, е просто „черешката на тортата“. Нетърпелив съм да ги проуча допълнително за повече случаи на употреба.