Задачите за пропуски и острови са класически предизвикателства за заявки, при които трябва да идентифицирате диапазони от липсващи стойности и диапазони от съществуващи стойности в последователност. Последователността често се основава на някаква дата или стойности за дата и час, които обикновено трябва да се появяват на редовни интервали, но някои записи липсват. Задачата за пропуски търси липсващите периоди, а задачата за острови търси съществуващите периоди. Покрих много решения на задачи за пропуски и острови в моите книги и статии в миналото. Наскоро ми беше представено ново специално предизвикателство за острови от моя приятел Адам Мачаник и решаването му изискваше малко креативност. В тази статия представям предизвикателството и решението, което измислих.
Предизвикателството
Във вашата база данни вие следите услугите, които вашата компания поддържа в таблица, наречена CompanyServices, и всяка услуга обикновено отчита около веднъж в минута, че е онлайн в таблица, наречена EventLog. Следният код създава тези таблици и ги попълва с малки набори от примерни данни:
SET NOCOUNT ON; USE tempdb; IF OBJECT_ID(N'dbo.EventLog') IS NOT NULL DROP TABLE dbo.EventLog; IF OBJECT_ID(N'dbo.CompanyServices') IS NOT NULL DROP TABLE dbo.CompanyServices; CREATE TABLE dbo.CompanyServices ( serviceid INT NOT NULL, CONSTRAINT PK_CompanyServices PRIMARY KEY(serviceid) ); GO INSERT INTO dbo.CompanyServices(serviceid) VALUES(1), (2), (3); CREATE TABLE dbo.EventLog ( logid INT NOT NULL IDENTITY, serviceid INT NOT NULL, logtime DATETIME2(0) NOT NULL, CONSTRAINT PK_EventLog PRIMARY KEY(logid) ); GO INSERT INTO dbo.EventLog(serviceid, logtime) VALUES (1, '20180912 08:00:00'), (1, '20180912 08:01:01'), (1, '20180912 08:01:59'), (1, '20180912 08:03:00'), (1, '20180912 08:05:00'), (1, '20180912 08:06:02'), (2, '20180912 08:00:02'), (2, '20180912 08:01:03'), (2, '20180912 08:02:01'), (2, '20180912 08:03:00'), (2, '20180912 08:03:59'), (2, '20180912 08:05:01'), (2, '20180912 08:06:01'), (3, '20180912 08:00:01'), (3, '20180912 08:03:01'), (3, '20180912 08:04:02'), (3, '20180912 08:06:00'); SELECT * FROM dbo.EventLog;
В момента таблицата EventLog е попълнена със следните данни:
logid serviceid logtime ----------- ----------- --------------------------- 1 1 2018-09-12 08:00:00 2 1 2018-09-12 08:01:01 3 1 2018-09-12 08:01:59 4 1 2018-09-12 08:03:00 5 1 2018-09-12 08:05:00 6 1 2018-09-12 08:06:02 7 2 2018-09-12 08:00:02 8 2 2018-09-12 08:01:03 9 2 2018-09-12 08:02:01 10 2 2018-09-12 08:03:00 11 2 2018-09-12 08:03:59 12 2 2018-09-12 08:05:01 13 2 2018-09-12 08:06:01 14 3 2018-09-12 08:00:01 15 3 2018-09-12 08:03:01 16 3 2018-09-12 08:04:02 17 3 2018-09-12 08:06:00
Специалната задача на островите е да идентифицира периодите на наличност (обслужен, начален, краен час). Една уловка е, че няма гаранция, че дадена услуга ще докладва, че е онлайн точно всяка минута; трябва да толерирате интервал до, да речем, 66 секунди от предишния запис в дневника и все пак да го считате за част от същия период на наличност (остров). След 66 секунди, новият запис в дневника започва нов период на наличност. И така, за входните примерни данни по-горе, вашето решение трябва да върне следния набор от резултати (не непременно в този ред):
serviceid starttime endtime ----------- --------------------------- --------------------------- 1 2018-09-12 08:00:00 2018-09-12 08:03:00 1 2018-09-12 08:05:00 2018-09-12 08:06:02 2 2018-09-12 08:00:02 2018-09-12 08:06:01 3 2018-09-12 08:00:01 2018-09-12 08:00:01 3 2018-09-12 08:03:01 2018-09-12 08:04:02 3 2018-09-12 08:06:00 2018-09-12 08:06:00
Забележете, например, как запис в дневника 5 започва нов остров, тъй като интервалът от предишния запис в дневника е 120 секунди (> 66), докато запис в дневника 6 не започва нов остров, тъй като интервалът от предишния запис е 62 секунди ( <=66). Друга уловка е, че Адам искаше решението да бъде съвместимо с пред-SQL Server 2012 среди, което го прави много по-трудно предизвикателство, тъй като не можете да използвате агрегатни функции на прозореца с рамка за изчисляване на текущи суми и функции на прозореца за изместване като LAG и LEAD. Както обикновено, предлагам да опитате сами да решите предизвикателството, преди да разгледате моите решения. Използвайте малките набори от примерни данни, за да проверите валидността на вашите решения. Използвайте следния код, за да попълните таблиците си с големи набори от примерни данни (500 услуги, ~10 милиона записи в регистрационния файл, за да тествате производителността на вашите решения):
-- Helper function dbo.GetNums IF OBJECT_ID(N'dbo.GetNums') IS NOT NULL DROP FUNCTION dbo.GetNums; GO CREATE FUNCTION dbo.GetNums(@low AS BIGINT, @high AS BIGINT) RETURNS TABLE AS RETURN WITH L0 AS (SELECT c FROM (SELECT 1 UNION ALL SELECT 1) AS D(c)), L1 AS (SELECT 1 AS c FROM L0 AS A CROSS JOIN L0 AS B), L2 AS (SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B), L3 AS (SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B), L4 AS (SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B), L5 AS (SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B), Nums AS (SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum FROM L5) SELECT TOP(@high - @low + 1) @low + rownum - 1 AS n FROM Nums ORDER BY rownum; GO -- ~10,000,000 intervals DECLARE @numservices AS INT = 500, @logsperservice AS INT = 20000, @enddate AS DATETIME2(0) = '20180912', @validinterval AS INT = 60, -- seconds @normdifferential AS INT = 3, -- seconds @percentmissing AS FLOAT = 0.01; TRUNCATE TABLE dbo.EventLog; TRUNCATE TABLE dbo.CompanyServices; INSERT INTO dbo.CompanyServices(serviceid) SELECT A.n AS serviceid FROM dbo.GetNums(1, @numservices) AS A; WITH C AS ( SELECT S.n AS serviceid, DATEADD(second, -L.n * @validinterval + CHECKSUM(NEWID()) % (@normdifferential + 1), @enddate) AS logtime, RAND(CHECKSUM(NEWID())) AS rnd FROM dbo.GetNums(1, @numservices) AS S CROSS JOIN dbo.GetNums(1, @logsperservice) AS L ) INSERT INTO dbo.EventLog WITH (TABLOCK) (serviceid, logtime) SELECT serviceid, logtime FROM C WHERE rnd > @percentmissing;
Резултатите, които ще предоставя за стъпките на моите решения, ще приемат малките набори от примерни данни, а числата за производителност, които ще предоставя, ще приемат големите набори.
Всички решения, които ще представя, се възползват от следния индекс:
CREATE INDEX idx_sid_ltm_lid ON dbo.EventLog(serviceid, logtime, logid);
Успех!
Решение 1 за SQL Server 2012+
Преди да разгледам решение, което е съвместимо със среди преди SQL Server 2012, ще разгледам едно, което изисква минимум SQL Server 2012. Ще го нарека Решение 1.
Първата стъпка в решението е да се изчисли флаг, наречен isstart, който е 0, ако събитието не стартира нов остров, и 1 в противен случай. Това може да се постигне чрез използване на функцията LAG за получаване на регистрационния час на предишното събитие и проверка дали времевата разлика в секунди между предишното и текущото събитие е по-малка или равна на позволената разлика. Ето кода, който изпълнява тази стъпка:
DECLARE @allowedgap AS INT = 66; -- in seconds SELECT *, CASE WHEN DATEDIFF(second, LAG(logtime) OVER(PARTITION BY serviceid ORDER BY logtime, logid), logtime) <= @allowedgap THEN 0 ELSE 1 END AS isstart FROM dbo.EventLog;
Този код генерира следния изход:
logid serviceid logtime isstart ----------- ----------- --------------------------- ----------- 1 1 2018-09-12 08:00:00 1 2 1 2018-09-12 08:01:01 0 3 1 2018-09-12 08:01:59 0 4 1 2018-09-12 08:03:00 0 5 1 2018-09-12 08:05:00 1 6 1 2018-09-12 08:06:02 0 7 2 2018-09-12 08:00:02 1 8 2 2018-09-12 08:01:03 0 9 2 2018-09-12 08:02:01 0 10 2 2018-09-12 08:03:00 0 11 2 2018-09-12 08:03:59 0 12 2 2018-09-12 08:05:01 0 13 2 2018-09-12 08:06:01 0 14 3 2018-09-12 08:00:01 1 15 3 2018-09-12 08:03:01 1 16 3 2018-09-12 08:04:02 0 17 3 2018-09-12 08:06:00 1
След това обикновена текуща сума на флага isstart произвежда идентификатор на острова (ще го нарека grp). Ето кода, който изпълнява тази стъпка:
DECLARE @allowedgap AS INT = 66; WITH C1 AS ( SELECT *, CASE WHEN DATEDIFF(second, LAG(logtime) OVER(PARTITION BY serviceid ORDER BY logtime, logid), logtime) <= @allowedgap THEN 0 ELSE 1 END AS isstart FROM dbo.EventLog ) SELECT *, SUM(isstart) OVER(PARTITION BY serviceid ORDER BY logtime, logid ROWS UNBOUNDED PRECEDING) AS grp FROM C1;
Този код генерира следния изход:
logid serviceid logtime isstart grp ----------- ----------- --------------------------- ----------- ----------- 1 1 2018-09-12 08:00:00 1 1 2 1 2018-09-12 08:01:01 0 1 3 1 2018-09-12 08:01:59 0 1 4 1 2018-09-12 08:03:00 0 1 5 1 2018-09-12 08:05:00 1 2 6 1 2018-09-12 08:06:02 0 2 7 2 2018-09-12 08:00:02 1 1 8 2 2018-09-12 08:01:03 0 1 9 2 2018-09-12 08:02:01 0 1 10 2 2018-09-12 08:03:00 0 1 11 2 2018-09-12 08:03:59 0 1 12 2 2018-09-12 08:05:01 0 1 13 2 2018-09-12 08:06:01 0 1 14 3 2018-09-12 08:00:01 1 1 15 3 2018-09-12 08:03:01 1 2 16 3 2018-09-12 08:04:02 0 2 17 3 2018-09-12 08:06:00 1 3
И накрая, вие групирате редовете по идентификатор на услугата и идентификатора на острова и връщате минималните и максималните регистрационни времена като начален и краен час на всеки остров. Ето пълното решение:
DECLARE @allowedgap AS INT = 66; WITH C1 AS ( SELECT *, CASE WHEN DATEDIFF(second, LAG(logtime) OVER(PARTITION BY serviceid ORDER BY logtime, logid), logtime) <= @allowedgap THEN 0 ELSE 1 END AS isstart FROM dbo.EventLog ), C2 AS ( SELECT *, SUM(isstart) OVER(PARTITION BY serviceid ORDER BY logtime, logid ROWS UNBOUNDED PRECEDING) AS grp FROM C1 ) SELECT serviceid, MIN(logtime) AS starttime, MAX(logtime) AS endtime FROM C2 GROUP BY serviceid, grp;
Завършването на това решение отне 41 секунди в моята система и създаде плана, показан на фигура 1.
Фигура 1:План за решение 1
Както можете да видите, и двете функции на прозореца се изчисляват въз основа на реда на индексите, без да е необходимо изрично сортиране.
Ако използвате SQL Server 2016 или по-нова версия, можете да използвате трика, който разглеждам тук, за да активирате оператора Window Aggregate в пакетен режим, като създадете празен филтриран индекс на columnstore, както следва:
CREATE NONCLUSTERED COLUMNSTORE INDEX idx_cs ON dbo.EventLog(logid) WHERE logid = -1 AND logid = -2;
Завършването на същото решение сега отнема само 5 секунди в моята система, като се получава планът, показан на фигура 2.
Фигура 2:Планиране на решение 1 с помощта на оператора Window Aggregate в пакетния режим
Всичко това е страхотно, но както споменахме, Адам търсеше решение, което може да работи в среди преди 2012 г.
Преди да продължите, уверете се, че сте изхвърлили индекса на columnstore за почистване:
DROP INDEX idx_cs ON dbo.EventLog;
Решение 2 за среди преди SQL Server 2012
За съжаление, преди SQL Server 2012, нямахме поддръжка за функции на офсетни прозорци като LAG, нито пък имахме поддръжка за изчисляване на текущи суми с агрегатни функции на прозореца с рамка. Това означава, че ще трябва да работите много повече, за да намерите разумно решение.
Трикът, който използвах, е да превърна всеки запис в дневника в изкуствен интервал, чийто начален час е времето на записа и чийто крайен час е времето на записа плюс разрешената празнина. След това можете да третирате задачата като класическа задача за пакетиране на интервали.
Първата стъпка в решението изчислява изкуствените ограничители на интервали и номерата на редове, маркиращи позициите на всеки от видовете събития (counteach). Ето кода, който изпълнява тази стъпка:
DECLARE @allowedgap AS INT = 66; SELECT logid, serviceid, logtime AS s, -- important, 's' > 'e', for later ordering DATEADD(second, @allowedgap, logtime) AS e, ROW_NUMBER() OVER(PARTITION BY serviceid ORDER BY logtime, logid) AS counteach FROM dbo.EventLog;
Този код генерира следния изход:
logid serviceid s e counteach ------ ---------- -------------------- -------------------- ---------- 1 1 2018-09-12 08:00:00 2018-09-12 08:01:06 1 2 1 2018-09-12 08:01:01 2018-09-12 08:02:07 2 3 1 2018-09-12 08:01:59 2018-09-12 08:03:05 3 4 1 2018-09-12 08:03:00 2018-09-12 08:04:06 4 5 1 2018-09-12 08:05:00 2018-09-12 08:06:06 5 6 1 2018-09-12 08:06:02 2018-09-12 08:07:08 6 7 2 2018-09-12 08:00:02 2018-09-12 08:01:08 1 8 2 2018-09-12 08:01:03 2018-09-12 08:02:09 2 9 2 2018-09-12 08:02:01 2018-09-12 08:03:07 3 10 2 2018-09-12 08:03:00 2018-09-12 08:04:06 4 11 2 2018-09-12 08:03:59 2018-09-12 08:05:05 5 12 2 2018-09-12 08:05:01 2018-09-12 08:06:07 6 13 2 2018-09-12 08:06:01 2018-09-12 08:07:07 7 14 3 2018-09-12 08:00:01 2018-09-12 08:01:07 1 15 3 2018-09-12 08:03:01 2018-09-12 08:04:07 2 16 3 2018-09-12 08:04:02 2018-09-12 08:05:08 3 17 3 2018-09-12 08:06:00 2018-09-12 08:07:06 4
Следващата стъпка е да развъртите интервалите в хронологична последователност от начални и крайни събития, идентифицирани като типове събития „s“ и „e“, съответно. Имайте предвид, че изборът на букви s и e е важен ('s' > 'e'
). Тази стъпка изчислява номерата на редове, маркиращи правилния хронологичен ред на двата вида събития, които сега се преплитат (броят и двете). В случай, че един интервал завършва точно там, където започва друг, като позиционирате стартовото събитие преди крайното събитие, вие ще ги опаковате заедно. Ето кода, който изпълнява тази стъпка:
DECLARE @allowedgap AS INT = 66; WITH C1 AS ( SELECT logid, serviceid, logtime AS s, -- important, 's' > 'e', for later ordering DATEADD(second, @allowedgap, logtime) AS e, ROW_NUMBER() OVER(PARTITION BY serviceid ORDER BY logtime, logid) AS counteach FROM dbo.EventLog ) SELECT logid, serviceid, logtime, eventtype, counteach, ROW_NUMBER() OVER(PARTITION BY serviceid ORDER BY logtime, eventtype DESC, logid) AS countboth FROM C1 UNPIVOT(logtime FOR eventtype IN (s, e)) AS U;
Този код генерира следния изход:
logid serviceid logtime eventtype counteach countboth ------ ---------- -------------------- ---------- ---------- ---------- 1 1 2018-09-12 08:00:00 s 1 1 2 1 2018-09-12 08:01:01 s 2 2 1 1 2018-09-12 08:01:06 e 1 3 3 1 2018-09-12 08:01:59 s 3 4 2 1 2018-09-12 08:02:07 e 2 5 4 1 2018-09-12 08:03:00 s 4 6 3 1 2018-09-12 08:03:05 e 3 7 4 1 2018-09-12 08:04:06 e 4 8 5 1 2018-09-12 08:05:00 s 5 9 6 1 2018-09-12 08:06:02 s 6 10 5 1 2018-09-12 08:06:06 e 5 11 6 1 2018-09-12 08:07:08 e 6 12 7 2 2018-09-12 08:00:02 s 1 1 8 2 2018-09-12 08:01:03 s 2 2 7 2 2018-09-12 08:01:08 e 1 3 9 2 2018-09-12 08:02:01 s 3 4 8 2 2018-09-12 08:02:09 e 2 5 10 2 2018-09-12 08:03:00 s 4 6 9 2 2018-09-12 08:03:07 e 3 7 11 2 2018-09-12 08:03:59 s 5 8 10 2 2018-09-12 08:04:06 e 4 9 12 2 2018-09-12 08:05:01 s 6 10 11 2 2018-09-12 08:05:05 e 5 11 13 2 2018-09-12 08:06:01 s 7 12 12 2 2018-09-12 08:06:07 e 6 13 13 2 2018-09-12 08:07:07 e 7 14 14 3 2018-09-12 08:00:01 s 1 1 14 3 2018-09-12 08:01:07 e 1 2 15 3 2018-09-12 08:03:01 s 2 3 16 3 2018-09-12 08:04:02 s 3 4 15 3 2018-09-12 08:04:07 e 2 5 16 3 2018-09-12 08:05:08 e 3 6 17 3 2018-09-12 08:06:00 s 4 7 17 3 2018-09-12 08:07:06 e 4 8
Както бе споменато, counteach маркира позицията на събитието само сред събития от един и същи вид, а countboth маркира позицията на събитието сред комбинираните, преплетени събития от двата вида.
След това магията се обработва от следващата стъпка – изчисляване на броя на активните интервали след всяко събитие въз основа на counteach и countboth. Броят на активните интервали е броят на началните събития, които са се случили досега, минус броя на крайните събития, които са се случили досега. За начални събития counteach ви казва колко начални събития са се случили досега и можете да разберете колко са приключили досега, като извадите counteach от countboth. И така, пълният израз, който ви казва колко интервала са активни, тогава е:
counteach - (countboth - counteach)
За крайни събития counteach ви казва колко крайни събития са се случили досега и можете да разберете колко са започнали досега, като извадите counteach от countboth. И така, пълният израз, който ви казва колко интервала са активни, тогава е:
(countboth - counteach) - counteach
Използвайки следния CASE израз, изчислявате колоната с активен брой на базата на типа събитие:
CASE WHEN eventtype = 's' THEN counteach - (countboth - counteach) WHEN eventtype = 'e' THEN (countboth - counteach) - counteach END
В същата стъпка филтрирате само събития, представляващи началото и края на пакетирани интервали. Началите на пакетирани интервали имат тип „s“ и countactive 1. Краищата на пакетирани интервали имат тип „e“ и countactive 0.
След филтриране оставате с двойки събития в началото и края на пакетирани интервали, но всяка двойка е разделена на два реда – един за началното събитие и друг за крайното събитие. Следователно същата стъпка изчислява идентификатор на двойка, като използва номера на редове с формулата (rownum – 1) / 2 + 1.
Ето кода, който изпълнява тази стъпка:
DECLARE @allowedgap AS INT = 66; WITH C1 AS ( SELECT logid, serviceid, logtime AS s, -- important, 's' > 'e', for later ordering DATEADD(second, @allowedgap, logtime) AS e, ROW_NUMBER() OVER(PARTITION BY serviceid ORDER BY logtime, logid) AS counteach FROM dbo.EventLog ), C2 AS ( SELECT logid, serviceid, logtime, eventtype, counteach, ROW_NUMBER() OVER(PARTITION BY serviceid ORDER BY logtime, eventtype DESC, logid) AS countboth FROM C1 UNPIVOT(logtime FOR eventtype IN (s, e)) AS U ) SELECT serviceid, eventtype, logtime, (ROW_NUMBER() OVER(PARTITION BY serviceid ORDER BY logtime, eventtype DESC, logid) - 1) / 2 + 1 AS grp FROM C2 CROSS APPLY ( VALUES( CASE WHEN eventtype = 's' THEN counteach - (countboth - counteach) WHEN eventtype = 'e' THEN (countboth - counteach) - counteach END ) ) AS A(countactive) WHERE (eventtype = 's' AND countactive = 1) OR (eventtype = 'e' AND countactive = 0);
Този код генерира следния изход:
serviceid eventtype logtime grp ----------- ---------- -------------------- ---- 1 s 2018-09-12 08:00:00 1 1 e 2018-09-12 08:04:06 1 1 s 2018-09-12 08:05:00 2 1 e 2018-09-12 08:07:08 2 2 s 2018-09-12 08:00:02 1 2 e 2018-09-12 08:07:07 1 3 s 2018-09-12 08:00:01 1 3 e 2018-09-12 08:01:07 1 3 s 2018-09-12 08:03:01 2 3 e 2018-09-12 08:05:08 2 3 s 2018-09-12 08:06:00 3 3 e 2018-09-12 08:07:06 3
Последната стъпка завърта двойките събития в ред за интервал и изважда позволената празнина от крайното време, за да възстанови правилното време за събитие. Ето пълния код на решението:
DECLARE @allowedgap AS INT = 66; WITH C1 AS ( SELECT logid, serviceid, logtime AS s, -- important, 's' > 'e', for later ordering DATEADD(second, @allowedgap, logtime) AS e, ROW_NUMBER() OVER(PARTITION BY serviceid ORDER BY logtime, logid) AS counteach FROM dbo.EventLog ), C2 AS ( SELECT logid, serviceid, logtime, eventtype, counteach, ROW_NUMBER() OVER(PARTITION BY serviceid ORDER BY logtime, eventtype DESC, logid) AS countboth FROM C1 UNPIVOT(logtime FOR eventtype IN (s, e)) AS U ), C3 AS ( SELECT serviceid, eventtype, logtime, (ROW_NUMBER() OVER(PARTITION BY serviceid ORDER BY logtime, eventtype DESC, logid) - 1) / 2 + 1 AS grp FROM C2 CROSS APPLY ( VALUES( CASE WHEN eventtype = 's' THEN counteach - (countboth - counteach) WHEN eventtype = 'e' THEN (countboth - counteach) - counteach END ) ) AS A(countactive) WHERE (eventtype = 's' AND countactive = 1) OR (eventtype = 'e' AND countactive = 0) ) SELECT serviceid, s AS starttime, DATEADD(second, -@allowedgap, e) AS endtime FROM C3 PIVOT( MAX(logtime) FOR eventtype IN (s, e) ) AS P;
Завършването на това решение отне 43 секунди в моята система и генерира плана, показан на фигура 3.
Фигура 3:План за решение 2
Както можете да видите, изчисляването на номера на първия ред се изчислява въз основа на реда на индекса, но следващите два включват изрично сортиране. И все пак производителността не е толкова лоша, като се има предвид, че има около 10 000 000 замесени реда.
Въпреки че целта на това решение е да се използва среда преди SQL Server 2012, само за забавление, тествах неговата производителност, след като създадох филтриран индекс на columnstore, за да видя как се справя с активирана пакетна обработка:
CREATE NONCLUSTERED COLUMNSTORE INDEX idx_cs ON dbo.EventLog(logid) WHERE logid = -1 AND logid = -2;
С активирана пакетна обработка това решение отне 29 секунди, за да завърши в моята система, създавайки плана, показан на фигура 4.
Заключение
Естествено е, че колкото по-ограничена е вашата среда, толкова по-предизвикателно става решаването на задачи за запитване. Специалното предизвикателство на Адам е много по-лесно за решаване на по-нови версии на SQL Server, отколкото на по-стари. Но след това се принуждавате да използвате по-креативни техники. Така че като упражнение, за да подобрите уменията си за запитване, можете да се справите с предизвикателства, с които вече сте запознати, но умишлено да наложите определени ограничения. Никога не знаеш в какви интересни идеи може да се натъкнеш!