Thứ Ba, 12 tháng 1, 2016

Tìm kiếm số điện thoại

Hiện nay đa số các trang Web cho phép tìm kiếm và đăng ký số điện thoại mới chỉ hỗ trợ phương pháp tìm kiếm theo đầu hoặc đuôi của số máy cần tìm (ví dụ tìm số máy có đầu là 888 hoặc đuôi là 999). Lý do khá đơn giản, thông thường các kỹ sư xây dựng hệ thống sẽ tạo 01 chỉ mục (index) cho số máy điện thoại và 01 chỉ mục cho chuỗi đảo ngược (reverse) của số máy. Khi đó việc tìm kiếm sẽ được thực hiện trên những chỉ mục này tùy thuộc việc tìm theo đầu hay đuôi. Nếu tìm kiếm theo các điều kiện khác (ví dụ số máy có chứa số 666) thì hệ thống sẽ không sử dụng chỉ mục mà tiến hành quét toàn bộ (scan full) số máy điện thoại trên DB để tìm ra số máy phù hợp với điều kiện đưa ra. Điều đó khiến kết quả trả về chậm vì số lượng số máy điện thoại nhiều, trong trường có nhiều yêu cầu tìm kiếm sẽ dẫn đến “treo” hoặc “sập” hệ thống.

Sau đây, tôi giới thiệu một phương pháp khác cho phép chúng ta có thể sử dụng được rất nhiều các điều kiện tìm kiếm mà vẫn đảm bảo tốt thời gian truy vấn và hiệu năng hệ thống. Chúng ta sẽ sử dụng tính năng Oracle Text.

Đầu tiên, chúng ta kích hoạt tính năng Oracle Text trên Oracle DB. Ta có thể sử dụng công cụ Database Configuration Assistant để add tính năng này.

Sau đó chúng ta phải gán quyền sử dụng tính năng này cho user của chúng ta. Login vào DB với user sys hoặc system để gán quyền (user thử nghiệm của tôi là jars):

grant CTXAPP to jars;
grant execute on CTXSYS.CTX_CLS to jars;
grant execute on CTXSYS.CTX_DDL to jars;
grant execute on CTXSYS.CTX_DOC to jars;
grant execute on CTXSYS.CTX_OUTPUT to jars;
grant execute on CTXSYS.CTX_QUERY to jars;
grant execute on CTXSYS.CTX_REPORT to jars;
grant execute on CTXSYS.CTX_THES to jars;
grant execute on CTXSYS.CTX_ULEXER to jars;

Login lại với user vừa được gán quyền và bắt đầu tạo dữ liệu mẫu. Chúng ta sẽ tạo một bảng chứa các số điện thoại. Tôi tổ chức số máy thành 2 trường là NC (mã mạng) và ISDN (số máy). Ở đây tôi chỉ tạo mã mạng duy nhất là ‘91’.

drop table number_store;
create table number_store (
  nc varchar2(10),
  isdn varchar2(16),
  isdn_shadow varchar2(4000)
);
alter table number_store add (
      constraint number_store_pk primary key (nc, isdn));

Lưu ý trường ISDN_SHADOW. Trường này được tạo ra từ trường ISDN. Cụ thể với mỗi ISDN chúng ta sẽ phân rã thành tất cả các chuỗi số nhỏ hơn mà có thể xuất hiện trong số ISDN này, các số phân rã sẽ được phân cách bằng dấu trống (‘ ‘). Ví dụ với số ‘5385702’ sẽ được phân rã thành ‘5 53 538 5385 53857 538570 5385702 3 38 385 3857 38570 385702 8 85 857 8570 85702 57 570 5702 7 70 702 0 02 2’.

Lý do của việc này là do Oracle Text thực hiện tìm kiếm text dựa trên cách thức tìm kiếm theo từng từ, nếu ta không phân rã thì nó sẽ hiểu là 1 từ thôi và chỉ tìm được với điều kiện là toàn bộ số máy.

Ta viết một hàm phân rã và gán vào trigger của bảng NUMBER_STORE.

create or replace function string_to_words(p_string in varchar2) return varchar2
is
  l_n      number;
  l_piece  varchar2(255);
  l_return varchar2(4000):= ' ';
begin
  l_n:= length(p_string);
  for i in 1..l_n loop
    for j in 1..l_n-i+1 loop
      l_piece:= substr(p_string, i, j)||' ';
      if (nvl(instr(l_return,  ' ' || l_piece), 0) = 0) then
        l_return:= l_return || l_piece;
      end if;
    end loop;
  end loop;
  return l_return;
end;
/
create or replace trigger number_store_trigger
before insert or update on number_store for each row
begin
  :new.isdn_shadow:= string_to_words(:new.isdn);
end;
/

Đến đây ta bắt đầu insert dữ liệu mẫu. Ta tạo errorr log cho bảng NUMBER_STORE và sử dụng chỉ thị “log errors reject limit unlimited” trong câu lệnh insert để reject những bản ghi bị trùng khóa chính sang bảng error log. Với vòng lặp 10 lần, mỗi lần 100K bản ghi, như vậy ta có khoảng 1 triệu số (phải trừ đi những số máy bị reject).

exec dbms_errlog.create_error_log('NUMBER_STORE');
/
begin
  for i in 1..10 loop
    insert into number_store (nc, isdn)
      select '91', trunc(dbms_random.value(2000000, 9999999)) 
      from dual
        connect by level <= 100000
        log errors reject limit unlimited;
    commit;
  end loop;
end;
/

Chúng ta tạo text index cho trường ISDN_SHADOW:

create index number_store_search_ix on number_store(isdn_shadow)
  indextype is ctxsys.context
  parameters ('DATASTORE CTXSYS.DEFAULT_DATASTORE MEMORY 50M');;
/

Text index sẽ không tự cập nhập dữ liệu mới nên ta phải tiến hành chạy đồng bộ sau khi cập nhập dữ liệu mới. Quá trình đồng bộ rất nhanh do Oracle chỉ thực đồng bộ những dữ liệu bị thay đổi. Ta có thể tạo job đồng bộ định kỳ (1h/lần).

declare
  x number;
begin
  sys.dbms_job.submit(
    job       => x,
    what      => 'ctx_ddl.sync_index(''number_store_search_ix'', ''50m'');',
    next_date => sysdate+1/86400,
    interval  => 'sysdate+1/24',
    no_parse  => false
  );
  sys.dbms_output.put_line('job number is: ' || to_char(x));
commit;
end;
/

Phù!!! Đến đây mọi việc đã xong. Chúng ta bắt đầu thử tìm kiếm với các điều kiện khác nhau nhé, từ đơn giản đến phức tạp.

Tìm số có đầu là ‘4567’:
select * from number_store where contains(isdn_shadow, '{4567}') > 0 and (isdn like '4567%');

Tìm số có đuôi là ‘4567’:
select * from number_store where contains(isdn_shadow, '{4567}') > 0 and (isdn like '%4567');

Tìm số có chứa ‘4567’:
select * from number_store where contains(isdn_shadow, '{4567}') > 0 and (isdn like '%4567%');

Tìm số có chứa ‘88’ và có đuôi là ‘666:
select * from number_store where contains(isdn_shadow, '{88} and {666}') > 0 and (isdn like '%88%666');

Tìm số có chứa ‘47’, ‘6’ và ‘2’:
select * from number_store where contains(isdn_shadow, '{47} and {6} and {2}') > 0 and (isdn like '47%6%2%');


Các bạn có thể thử với các điều kiện khác nữa. Thời gian truy vấn thông thường đều dưới 0.5 giây :-)

Chủ Nhật, 10 tháng 1, 2016

Nhóm các bản ghi có ngày liền nhau

Chiều nay vô tình vào diễn đàn Oracle Việt Nam xem chơi, tôi thấy một bạn có post bài như này:

            NHỜ GIÚP ĐỠ VIẾT CÂU LỆNH THỰC HIỆN TRUY VẤN THEO YÊU CẦU SAU ĐÂY
recno______MA_____QUAN________NGAY THAY DOI
1__________AAA_____10__________15/03/2007
2__________AAA_____10__________20/05/2007
3__________AAA_____01__________17/01/2009
4__________AAA_____01__________25/05/2009
5__________AAA_____03__________03/01/2011
6__________AAA_____03__________09/12/2011
7__________AAA_____03__________19/05/2012
8__________AAA_____04__________20/12/2012
9__________AAA_____04__________21/12/2012
10_________AAA_____01__________02/02/2013
11_________AAA_____01__________30/06/2013
12_________AAA_____HM__________30/09/2013
13_________BBB_____11__________12/03/2011
14_________BBB_____11__________20/12/2012
15_________BBB_____12__________01/03/2013
16_________CCC_____GV__________03/01/2010
17_________CCC_____GV__________06/05/2011
18_________CCC_____TB__________01/12/2012
19_________CCC_____TB__________08/08/2013
Mô tả   
- Cty có MST AAA bắt đầu kinh doanh ngày 15/03/2007 ở quận 10 (recno = 1)   
- Đến 17/01/2009 chuyển đến kinh doanh tại quân 1 (recno = 3) 
- Ngày 03/01/2011 chuyển về quận 3 (recno = 5)           
- Sau đó chuyển về quận 4 và về lại quận 1 ngày 02/02/2013 (recno = 10)         
- Đến 30/09/2013 về Hóc Môn (recno = 12)       
- Các cty có MST = BBB; CCC không quan tâm
- Trong dữ liệu thật, có rất nhiều cty có lịch sử thay đổi địa bàn kinh doanh như AAA     
Yêu cầu           
- Xác định thời gian cty AAA (và những cty khác tương tự) kinh doanh tại quận 1           
- Thời gian kinh doanh tại quận 1 được xác định như sau:          
+ từ 17/01/2009 đến 03/01/2011
+ từ 02/02/2013 đến 30/09/2013
Nhờ bạn nào viết giúp mình câu truy vấn để thực hiện yêu cầu trên.


Và có rất nhiều thành viên trong diễn đàn đã tham gia giúp đỡ, đa số viết cho riêng trường hợp công ty AAA như yêu cầu. Chỉ có một số ít bạn viết thử câu truy vấn tổng quát cho tất cả các công ty nhưng hoặc không đúng, hoặc câu lệnh rất dài, khó hiểu và cũng chả đúng.

Thực tế yêu cầu như này gặp trong rất nhiều các hệ thống nghiệp vụ, tôi nhớ đã từng làm ở đâu đó trước đây rồi. Nghĩ không khó nên tôi bắt tay vào thử luôn. Thế là mất cả buổi chiều và rồi cũng xong. Quả thực rất vui !!!

Đầu tiên, ta tạo dữ liệu như yêu cầu:

drop table ccdr;
create table ccdr (
  rec_no       number,
  comp_id      varchar2(3), -- mã công ty
  district_id  varchar2(2), -- mã quận
  move_date    date); -- ngày thay đổi

insert into ccdr values (1, 'AAA', '10', to_date('15/03/2007', 'dd/mm/yyyy'));
insert into ccdr values (2, 'AAA', '10', to_date('20/05/2007', 'dd/mm/yyyy'));
insert into ccdr values (3, 'AAA', '01', to_date('17/01/2009', 'dd/mm/yyyy'));
insert into ccdr values (4, 'AAA', '01', to_date('25/05/2009', 'dd/mm/yyyy'));
insert into ccdr values (5, 'AAA', '03', to_date('03/01/2011', 'dd/mm/yyyy'));
insert into ccdr values (6, 'AAA', '03', to_date('09/12/2011', 'dd/mm/yyyy'));
insert into ccdr values (7, 'AAA', '03', to_date('19/05/2012', 'dd/mm/yyyy'));
insert into ccdr values (8, 'AAA', '04', to_date('20/12/2012', 'dd/mm/yyyy'));
insert into ccdr values (9, 'AAA', '04', to_date('21/12/2012', 'dd/mm/yyyy'));
insert into ccdr values (10, 'AAA', '01', to_date('02/02/2013', 'dd/mm/yyyy'));
insert into ccdr values (11, 'AAA', '01', to_date('30/06/2013', 'dd/mm/yyyy'));
insert into ccdr values (12, 'AAA', 'HM', to_date('30/09/2013', 'dd/mm/yyyy'));
insert into ccdr values (13, 'BBB', '11', to_date('12/03/2011', 'dd/mm/yyyy'));
insert into ccdr values (14, 'BBB', '11', to_date('20/12/2012', 'dd/mm/yyyy'));
insert into ccdr values (15, 'BBB', '12', to_date('01/03/2013', 'dd/mm/yyyy'));
insert into ccdr values (16, 'CCC', 'GV', to_date('03/01/2010', 'dd/mm/yyyy'));
insert into ccdr values (17, 'CCC', 'GV', to_date('06/05/2011', 'dd/mm/yyyy'));
insert into ccdr values (18, 'CCC', 'TB', to_date('01/12/2012', 'dd/mm/yyyy'));
insert into ccdr values (19, 'CCC', 'TB', to_date('08/08/2013', 'dd/mm/yyyy'));
commit;

Sau đây, để dễ hình dung, tôi sẽ giải thích từng bước trong câu lệnh của mình.

Trước hết, tôi nhận thấy với mỗi bản ghi, ta cần phải xác định được thời điểm bắt đầu (start_date) và kết thúc (end_date). Start_date thì dễ rồi, chính là trường move_date. Còn end_date sẽ là move_date của bản ghi tiếp theo (tính theo từng mã công ty). Có một số bản ghi vì nằm ở cuối nên không xác định được bản ghi tiếp theo nên end_date sẽ chính là ngày hiện tại (trunc(sysdate)). Câu lệnh đơn giản như này:

select rec_no, comp_id, district_id, move_date start_date,
  nvl(lead(move_date) over (partition by comp_id order by move_date), trunc(sysdate)) end_date
from ccdr;

Tiếp theo, ta cần phải tìm các bản ghi có start_date bằng với end_date của bản ghi trước đó (lưu ý là tính theo từng nhóm mã công ty và mã quận). Ta tạo thêm một trường mới (grp_no) để nhận biết các bản ghi này, ta gán giá trị ‘0’, các bản ghi còn lại là gán là ‘1’. Sử dụng từ khóa “with” để thực hiện truy vấn trên kết quả của câu lệnh trước đó.

with
  ccdr1 as (
    select rec_no, comp_id, district_id, move_date start_date,
      nvl(lead(move_date) over (partition by comp_id order by move_date), trunc(sysdate)) end_date
    from ccdr
  )
select comp_id, district_id, start_date, end_date,
  decode(start_date, lag(end_date) over (partition by comp_id, district_id order by start_date, end_date), 0, 1) grp_no
from ccdr1;

Câu lệnh tiếp theo sẽ giải thích tại sao ta gán giá trị ‘0’. Tôi sẽ cộng dồn trường gpr_no để tạo ra một trường mới (grp), khi đó những bản ghi có grp_no là 0 sẽ có cùng giá trị grp với bản ghi trước đó.

with
  ccdr1 as (
    select rec_no, comp_id, district_id, move_date start_date,
      nvl(lead(move_date) over (partition by comp_id order by move_date), trunc(sysdate)) end_date
    from ccdr
  ),
  ccdr2 as (
    select comp_id, district_id, start_date, end_date,
      decode(start_date, lag(end_date) over (partition by comp_id, district_id order by start_date, end_date), 0, 1) grp_no
    from ccdr1
  )
select comp_id, district_id, start_date, end_date,
  sum(grp_no) over (partition by comp_id, district_id order by start_date, end_date) grp
from ccdr2;

Và đây là câu lệnh cuối cùng. Dựa trên việc nhóm 3 trường comp_id, district_id, và grp của câu lệnh trước đó, ta tính giá trị min của start_date và max của end_date.

with
  ccdr1 as (
    select rec_no, comp_id, district_id, move_date start_date,
      nvl(lead(move_date) over (partition by comp_id order by move_date), trunc(sysdate)) end_date
    from ccdr
  ),
  ccdr2 as (
    select comp_id, district_id, start_date, end_date,
      decode(start_date, lag(end_date) over (partition by comp_id, district_id order by start_date, end_date), 0, 1) grp_no
    from ccdr1
  ),
  ccdr3 as (
    select comp_id, district_id, start_date, end_date,
      sum(grp_no) over (partition by comp_id, district_id order by start_date, end_date) grp
    from ccdr2)
select
  comp_id,
  district_id,
  min(start_date) start_date,
  max(end_date) end_date
from ccdr3
group by comp_id, district_id, grp
order by 1, 2, 3, 4;

Kết quả nhận được như này:

COMP_ID
DISTRICT_ID
START_DATE
END_DATE
AAA
01
1/17/2009
1/3/2011
AAA
01
2/2/2013
9/30/2013
AAA
03
1/3/2011
12/20/2012
AAA
04
12/20/2012
2/2/2013
AAA
10
3/15/2007
1/17/2009
AAA
HM
9/30/2013
1/10/2016
BBB
11
3/12/2011
3/1/2013
BBB
12
3/1/2013
1/10/2016
CCC
GV
1/3/2010
12/1/2012
CCC
TB
12/1/2012
1/10/2016

Hy vọng ngày mai ít việc thôi J

Thứ Bảy, 9 tháng 1, 2016

Tìm các dải số liên tiếp

Lãnh đạo chỗ tôi làm trước đây có yêu cầu chúng tôi tìm kiếm các dải số điện thoại liền nhau để cung cấp cho các khách hàng doanh nghiệp. Chúng tôi có một bảng dữ liệu chứa tất cả các số điện thoại chưa sử dụng. Dữ liệu khá lớn, cỡ chục triệu số. Nếu đơn thuần chỉ thống kê ra thôi thì không có gì, vấn đề là các sếp lại muốn tạo thành một chức năng truy vấn trên hệ thống quản lý số điện thoại và cung cấp cho các đại lý của công ty, cỡ khoảng hơn 100 đại lý được phép làm việc này. Và đương nhiên như vậy thì số lượng truy vấn sẽ rất nhiều, tốc độ truy vấn nếu chậm sẽ khiến cho cả hệ thống quá tải, thậm chí “sập” luôn.

Một số bạn "hăm hở" viết function PL/SQL, quét toàn bộ bảng với số điện thoại được sắp xếp và kiểm tra có liền nhau không, sau đó trả về kết quả. Thời gian xử lý lên tới gần 10 phút, mà đấy là lúc tải thấp. Không đạt yêu cầu.

Tôi nhận thấy rằng chúng ta hoàn toàn có thể thực hiện hiệu quả việc này bằng câu lệnh SQL. Sau một hồi thử nghiệm nhiều mẫu lệnh khác nhau, rồi tôi cũng tìm ra được một câu lệnh khá hiệu quả và ngắn gọn.

Giả sử chúng ta có bảng quản lý số điện thoại chưa sử dụng như sau:

create table number_store (
  nc varchar2(5),
  isdn varchar2(16),
  constraint number_store_pk primary key (nc, isdn)
);
/

Ở đây, NC là Network Code (mã mạng), ISDN là số máy. Ví dụ số máy của bạn là 913235623, thì NC là 91, ISDN là 3235623. Cặp NC, ISDN sẽ tạo thành khóa chính.

Sử dụng đoạn code sau để insert dữ liệu mẫu vào bảng NUMBER_STORE.

exec dbms_errlog.create_error_log('NUMBER_STORE');
/

begin
  for i in 1..50 loop
    insert into number_store
    select
      case ceil(dbms_random.value * 2)
        when 1 then '91'
        when 2 then '94'
      end nc,
      round(dbms_random.value(2000000, 9999999)) isdn
    from dual
    connect by level <= 100000
    log errors reject limit unlimited;
    commit;
  end loop;
end;
/

exec dbms_stats.gather_table_stats(user, 'NUMBER_STORE', cascade=>true );
/

Ta tạo ngẫu nhiên số điện thoại rồi insert vào bảng NUMBER_STORE. Tuy nhiên bảng này lại có khóa chính trên cặp NC và ISDN, quá trình sinh ra ngẫu nhiên nhiều khả năng lại tạo ra số đã được insert rồi, khi đó lệnh insert bị lỗi. Để bỏ qua các trường hợp này, tôi tạo bảng error log cho bảng NUMBER_STORE, sau đó dùng chỉ thị “log errors reject limit unlimited” để đưa các bản ghi lỗi vào bảng errror log.

Vòng lặp 50 lần, mỗi lần 100K số ngẫu nhiên sinh ra, như vậy ta sẽ có gần 5 triệu số (phải trừ đi những số điện thoại bị reject nhưng không nhiều). Thực hiện thêm lệnh gather_table_stats để cập nhập thông tin statictics cho bảng NUMBER_STORE nhằm tăng hiệu năng truy vấn.

Đến đây, quá trình tạo bảng dữ liệu mẫu đã xong, ta bắt đầu thực hiện truy vấn tìm các dải số liền nhau. Chạy câu lệnh SQL sau:

with x as (
  select nc, isdn, isdn - row_number() over (partition by nc order by isdn) isdn2
  from number_store)
select
  nc, min(isdn) first_isdn, max(isdn) last_isdn, count(isdn) isdn_count
from x
group by nc, isdn2
having count(isdn) > 1
order by isdn_count desc;

Điểm quan trọng nhất của câu lệnh trên là “isdn - row_number() over (partition by nc order by isdn) isdn2”. Các bạn sẽ nhận thấy là với một NC cụ thể, các ISDN liền nhau sẽ có ISDN2 giống nhau. Ta chỉ cần thực hiện group by nc, isdn2 và tính các giá trị min, max, count. Còn điều kiện “having count(isdn) > 1” để yêu cầu dải số phải có từ 2 số trở lên.

Hãy chạy thử và cảm nhận! Sẽ rất nhanh đấy.