Mô phỏng hàm vô hướng do người dùng định nghĩa theo cách không ngăn chặn sự song song


12

Tôi đang cố gắng xem liệu có cách nào để lừa SQL Server sử dụng một kế hoạch nhất định cho truy vấn không.

1. Môi trường

Hãy tưởng tượng bạn có một số dữ liệu được chia sẻ giữa các quy trình khác nhau. Vì vậy, giả sử chúng ta có một số kết quả thử nghiệm cần nhiều không gian. Sau đó, với mỗi quy trình, chúng tôi biết năm / tháng của kết quả thử nghiệm mà chúng tôi muốn sử dụng.

if object_id('dbo.SharedData') is not null
    drop table SharedData

create table dbo.SharedData (
    experiment_year int,
    experiment_month int,
    rn int,
    calculated_number int,
    primary key (experiment_year, experiment_month, rn)
)
go

Bây giờ, đối với mọi quy trình, chúng tôi có các tham số được lưu trong bảng

if object_id('dbo.Params') is not null
    drop table dbo.Params

create table dbo.Params (
    session_id int,
    experiment_year int,
    experiment_month int,
    primary key (session_id)
)
go

2. Kiểm tra dữ liệu

Hãy thêm một số dữ liệu thử nghiệm:

insert into dbo.Params (session_id, experiment_year, experiment_month)
select 1, 2014, 3 union all
select 2, 2014, 4 
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 3, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 4, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

3. Lấy kết quả

Bây giờ, thật dễ dàng để có được kết quả thử nghiệm bằng cách @experiment_year/@experiment_month:

create or alter function dbo.f_GetSharedData(@experiment_year int, @experiment_month int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.SharedData as d
    where
        d.experiment_year = @experiment_year and
        d.experiment_month = @experiment_month
)
go

Kế hoạch là tốt đẹp và song song:

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(2014, 4)
group by
    calculated_number

truy vấn 0 kế hoạch

nhập mô tả hình ảnh ở đây

4. Vấn đề

Nhưng, để làm cho việc sử dụng dữ liệu chung chung hơn một chút, tôi muốn có một chức năng khác - dbo.f_GetSharedDataBySession(@session_id int). Vì vậy, cách đơn giản sẽ là tạo các hàm vô hướng, dịch @session_id-> @experiment_year/@experiment_month:

create or alter function dbo.fn_GetExperimentYear(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_year
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

create or alter function dbo.fn_GetExperimentMonth(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_month
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

Và bây giờ chúng ta có thể tạo chức năng của mình:

create or alter function dbo.f_GetSharedDataBySession1(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
        dbo.fn_GetExperimentYear(@session_id),
        dbo.fn_GetExperimentMonth(@session_id)
    ) as d
)
go

truy vấn 1 kế hoạch

nhập mô tả hình ảnh ở đây

Tất nhiên, kế hoạch là như nhau, ngoại trừ nó, không song song, bởi vì các hàm vô hướng thực hiện truy cập dữ liệu làm cho toàn bộ kế hoạch nối tiếp .

Vì vậy, tôi đã thử một vài cách tiếp cận khác nhau, như, sử dụng các truy vấn con thay vì các hàm vô hướng:

create or alter function dbo.f_GetSharedDataBySession2(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
       (select p.experiment_year from dbo.Params as p where p.session_id = @session_id),
       (select p.experiment_month from dbo.Params as p where p.session_id = @session_id)
    ) as d
)
go

truy vấn 2 kế hoạch

nhập mô tả hình ảnh ở đây

Hoặc sử dụng cross apply

create or alter function dbo.f_GetSharedDataBySession3(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.Params as p
        cross apply dbo.f_GetSharedData(
            p.experiment_year,
            p.experiment_month
        ) as d
    where
        p.session_id = @session_id
)
go

truy vấn 3 kế hoạch

nhập mô tả hình ảnh ở đây

Nhưng tôi không thể tìm ra cách viết truy vấn này tốt như truy vấn sử dụng các hàm vô hướng.

Vài suy nghĩ:

  1. Về cơ bản điều tôi muốn là có thể bằng cách nào đó nói với SQL Server để tính toán trước các giá trị nhất định và sau đó chuyển chúng đi xa hơn như các hằng số.
  2. Điều có thể hữu ích là nếu chúng ta có một số gợi ý vật chất hóa trung gian . Tôi đã kiểm tra một vài biến thể (TVF đa câu hoặc cte có đầu), nhưng không có kế hoạch nào tốt bằng một biến thể có chức năng vô hướng cho đến nay
  3. Tôi biết về sự cải tiến sắp tới của SQL Server 2017 - Froid: Tối ưu hóa các chương trình bắt buộc trong cơ sở dữ liệu quan hệ . Tuy nhiên, tôi không chắc chắn nó sẽ giúp ích. Dù vậy, thật tốt khi được chứng minh là sai ở đây.

Thông tin thêm

Tôi đang sử dụng một hàm (thay vì chọn dữ liệu trực tiếp từ các bảng) bởi vì nó dễ sử dụng hơn trong nhiều truy vấn khác nhau, thường có @session_iddưới dạng tham số.

Tôi được yêu cầu so sánh thời gian thực hiện thực tế. Trong trường hợp cụ thể này

  • truy vấn 0 chạy trong ~ 500ms
  • truy vấn 1 chạy trong ~ 1500ms
  • truy vấn 2 chạy trong ~ 1500ms
  • truy vấn 3 chạy trong ~ 2000ms.

Kế hoạch số 2 có quét chỉ mục thay vì tìm kiếm, sau đó được lọc bởi các vị từ trên các vòng lặp lồng nhau. Kế hoạch số 3 không tệ lắm, nhưng vẫn làm được nhiều việc hơn và hoạt động chậm hơn kế hoạch số 0.

Giả sử rằng dbo.Paramshiếm khi được thay đổi và thường có khoảng 1-200 hàng, không quá, giả sử 2000 được mong đợi. Bây giờ là khoảng 10 cột và tôi không mong đợi thêm cột quá thường xuyên.

Số lượng hàng trong Params không cố định, vì vậy cứ mỗi hàng @session_idsẽ có một hàng. Số lượng cột không cố định, đó là một trong những lý do tôi không muốn gọi dbo.f_GetSharedData(@experiment_year int, @experiment_month int)từ mọi nơi, vì vậy tôi có thể thêm cột mới vào truy vấn này trong nội bộ. Tôi rất vui khi nghe bất kỳ ý kiến ​​/ đề xuất nào về vấn đề này, ngay cả khi nó có một số hạn chế.


Kế hoạch truy vấn với Froid sẽ tương tự như truy vấn2 ở trên, vì vậy, nó sẽ không đưa bạn đến giải pháp mà bạn muốn đạt được trong trường hợp này.
Karthik

Câu trả lời:


13

Bạn không thể thực sự đạt được một cách an toàn chính xác những gì bạn muốn trong SQL Server ngày hôm nay, tức là trong một câu lệnh và với việc thực thi song song, trong các hạn chế được nêu trong câu hỏi (như tôi nhận thấy chúng).

Vì vậy, câu trả lời đơn giản của tôi là không . Phần còn lại của câu trả lời này chủ yếu là một cuộc thảo luận về lý do tại sao, trong trường hợp đó là mối quan tâm.

Có thể có được một kế hoạch song song, như đã lưu ý trong câu hỏi, nhưng có hai giống chính, không có loại nào phù hợp với nhu cầu của bạn:

  1. Một vòng lặp lồng nhau tương quan tham gia, với một luồng phân phối vòng tròn ở cấp cao nhất. Cho rằng một hàng đơn được đảm bảo xuất phát từ Paramsmột session_idgiá trị cụ thể , phía bên trong sẽ chạy trên một luồng duy nhất, mặc dù nó được đánh dấu bằng biểu tượng song song. Đây là lý do tại sao kế hoạch 3 song song rõ ràng không thực hiện tốt; nó là trong thực tế nối tiếp.

  2. Sự thay thế khác là cho sự song song độc lập ở phía bên trong của các vòng lặp lồng nhau. Độc lập ở đây có nghĩa là các luồng được khởi động ở phía bên trong, và không chỉ là cùng một luồng như đang thực hiện phía bên ngoài của các vòng lặp lồng nhau tham gia. SQL Server chỉ hỗ trợ song song các vòng lặp lồng bên trong độc lập khi được đảm bảo là một hàng bên ngoài không có tham số nối tương quan ( kế hoạch 2 ).

Vì vậy, chúng ta có một sự lựa chọn của một kế hoạch song song là nối tiếp (do một luồng) với các giá trị tương quan mong muốn; hoặc một kế hoạch song song bên trong phải quét vì nó không có tham số để tìm kiếm. (Ngoài ra: Nó thực sự nên được phép lái song song bên trong bằng cách sử dụng chính xác một bộ tham số tương quan, nhưng nó chưa bao giờ được thực hiện, có lẽ vì lý do chính đáng).

Một câu hỏi tự nhiên là: tại sao chúng ta cần các thông số tương quan? Tại sao SQL Server không thể đơn giản tìm kiếm trực tiếp các giá trị vô hướng được cung cấp bởi ví dụ: truy vấn con?

Chà, SQL Server chỉ có thể 'tìm kiếm chỉ mục' bằng cách sử dụng các tham chiếu vô hướng đơn giản, ví dụ: tham chiếu hằng, biến, cột hoặc biểu thức (vì vậy kết quả hàm vô hướng cũng có thể đủ điều kiện). Một truy vấn con (hoặc xây dựng tương tự khác) đơn giản là quá phức tạp (và có khả năng không an toàn) để đẩy vào toàn bộ công cụ lưu trữ. Vì vậy, các toán tử kế hoạch truy vấn riêng biệt được yêu cầu. Đây là lần lượt yêu cầu tương quan, có nghĩa là không có sự song song của loại bạn muốn.

Nói chung, thực sự không có giải pháp nào tốt hơn các phương thức như gán các giá trị tra cứu cho các biến và sau đó sử dụng các giá trị trong các tham số hàm trong một câu lệnh riêng.

Bây giờ bạn có thể có các cân nhắc cục bộ cụ thể có nghĩa là lưu trữ các giá trị hiện tại của năm và tháng trong đó SESSION_CONTEXTlà đáng giá, tức là:

SELECT FGSD.calculated_number, COUNT_BIG(*)
FROM dbo.f_GetSharedData
(
    CONVERT(integer, SESSION_CONTEXT(N'experiment_year')), 
    CONVERT(integer, SESSION_CONTEXT(N'experiment_month'))
) AS FGSD
GROUP BY FGSD.calculated_number;

Nhưng điều này rơi vào loại giải pháp.

Mặt khác, nếu hiệu suất tổng hợp có tầm quan trọng chính, bạn có thể xem xét việc gắn bó với các hàm nội tuyến và tạo một chỉ mục cột (chính hoặc phụ) trên bảng. Bạn có thể tìm thấy những lợi ích của việc lưu trữ cột, xử lý chế độ hàng loạt và đẩy xuống tổng hợp mang lại lợi ích lớn hơn so với tìm kiếm song song ở chế độ hàng.

Nhưng hãy cẩn thận với các hàm T-SQL vô hướng, đặc biệt là với bộ lưu trữ cột, vì nó dễ kết thúc với hàm được đánh giá mỗi hàng trong Bộ lọc chế độ hàng riêng biệt. Nhìn chung khá khó để đảm bảo số lần SQL Server sẽ chọn để đánh giá vô hướng và tốt hơn là không nên thử.


Cảm ơn, Paul, câu trả lời tuyệt vời! Tôi đã nghĩ về việc sử dụng session_contextnhưng tôi quyết định rằng đó là một ý tưởng quá điên rồ đối với tôi và tôi không chắc nó sẽ phù hợp với kiến ​​trúc hiện tại của tôi như thế nào. Điều có thể hữu ích là, có thể là, một số gợi ý mà tôi có thể sử dụng để cho trình tối ưu hóa biết rằng nó nên xử lý kết quả của truy vấn con như một tham chiếu vô hướng đơn giản.
Roman Pekar

8

Theo như tôi biết thì hình dạng kế hoạch mà bạn muốn không thể thực hiện được chỉ với T-SQL. Có vẻ như bạn muốn hình dạng kế hoạch ban đầu (kế hoạch truy vấn 0) với các truy vấn con từ các chức năng của bạn đang được áp dụng làm bộ lọc trực tiếp chống lại việc quét chỉ mục cụm. Bạn sẽ không bao giờ có được một gói truy vấn như thế nếu bạn không sử dụng các biến cục bộ để giữ các giá trị trả về của các hàm vô hướng. Thay vào đó, quá trình lọc sẽ được thực hiện như một phép nối vòng lặp lồng nhau. Có ba cách khác nhau (theo quan điểm song song) mà phép nối vòng có thể được thực hiện:

  1. Toàn bộ kế hoạch là nối tiếp. Điều này không được bạn chấp nhận. Đây là kế hoạch mà bạn nhận được cho truy vấn 1.
  2. Các vòng lặp tham gia chạy nối tiếp. Tôi tin rằng trong trường hợp này, bên trong có thể chạy song song, nhưng không thể truyền bất kỳ vị từ nào xuống nó. Vì vậy, hầu hết các công việc sẽ được thực hiện song song, nhưng bạn đang quét toàn bộ bảng và tổng hợp một phần đắt hơn nhiều so với trước đây. Đây là kế hoạch mà bạn nhận được cho truy vấn 2.
  3. Các vòng lặp tham gia chạy song song. Với vòng lặp lồng nhau song song tham gia vào bên trong của vòng lặp chạy nối tiếp nhưng bạn có thể có tối đa các luồng DOP chạy ở phía bên trong cùng một lúc. Tập kết quả bên ngoài của bạn sẽ chỉ có một hàng duy nhất, vì vậy kế hoạch song song của bạn sẽ có hiệu lực nối tiếp. Đây là kế hoạch mà bạn nhận được cho truy vấn 3.

Đó là những hình dạng kế hoạch khả dĩ duy nhất mà tôi biết. Bạn có thể nhận được một số thứ khác nếu bạn sử dụng bảng tạm thời nhưng không ai trong số họ giải quyết vấn đề cơ bản của bạn nếu bạn muốn hiệu suất truy vấn tốt như truy vấn 0.

Bạn có thể đạt được hiệu suất truy vấn tương đương bằng cách sử dụng các UDF vô hướng để gán giá trị trả về cho các biến cục bộ và sử dụng các biến cục bộ đó trong truy vấn của bạn. Bạn có thể bọc mã đó trong một thủ tục được lưu trữ hoặc UDF đa câu lệnh để tránh các vấn đề về bảo trì. Ví dụ:

DECLARE @experiment_year int = dbo.fn_GetExperimentYear(@session_id);
DECLARE @experiment_month int = dbo.fn_GetExperimentMonth(@session_id);

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(@experiment_year, @experiment_month)
group by
    calculated_number;

Các UDF vô hướng đã được di chuyển ra ngoài truy vấn mà bạn muốn đủ điều kiện để song song. Kế hoạch truy vấn mà tôi nhận được dường như là kế hoạch mà bạn muốn:

kế hoạch truy vấn song song

Cả hai cách tiếp cận đều có nhược điểm nếu bạn cần sử dụng tập kết quả này trong các truy vấn khác. Bạn không thể trực tiếp tham gia vào một thủ tục được lưu trữ. Bạn phải lưu kết quả vào bảng tạm thời có vấn đề riêng. Bạn có thể tham gia MS-TVF, nhưng trong SQL Server 2016, bạn có thể thấy các vấn đề ước tính về số lượng thẻ. SQL Server 2017 cung cấp thực thi xen kẽ cho MS-TVF có thể giải quyết vấn đề hoàn toàn.

Chỉ cần làm rõ vài điều: Các UDF vô hướng T-SQL luôn cấm song song và Microsoft đã không nói rằng FROID sẽ có sẵn trong SQL Server 2017.


liên quan đến Froid trong SQL 2017 - không chắc tại sao tôi nghĩ nó ở đó. Nó được xác nhận là trong vNext - brentozar.com/archive/2018/01/ từ
Roman Pekar

4

Điều này rất có thể được thực hiện bằng cách sử dụng SQLCLR. Một lợi ích của các UDF vô hướng SQLCLR là chúng không ngăn chặn sự song song nếu chúng không thực hiện bất kỳ truy cập dữ liệu nào (và đôi khi cũng cần được đánh dấu là "xác định"). Vậy làm thế nào để bạn sử dụng một cái gì đó không yêu cầu truy cập dữ liệu khi hoạt động yêu cầu truy cập dữ liệu?

Vâng, bởi vì dbo.Paramsbảng dự kiến ​​sẽ:

  1. nói chung không bao giờ có hơn 2000 hàng trong đó,
  2. hiếm khi thay đổi cấu trúc,
  3. chỉ (hiện tại) cần có hai INTcột

có thể lưu trữ ba cột - session_id, experiment_year int, experiment_month- vào một bộ sưu tập tĩnh (ví dụ: Từ điển, có lẽ) được đưa ra ngoài quy trình và được đọc bởi các UDF vô hướng có được các giá trị experiment_year intexperiment_month. Ý tôi là "ngoài quy trình" là: bạn có thể có một Quy trình UDF hoặc Thủ tục lưu trữ SQLCLR hoàn toàn riêng biệt có thể truy cập dữ liệu và đọc từ dbo.Paramsbảng để điền vào bộ sưu tập tĩnh. UDF hoặc Thủ tục lưu trữ đó sẽ được thực thi trước khi sử dụng các UDF có giá trị "năm" và "tháng", theo cách đó, các UDF có giá trị "năm" và "tháng" không thực hiện bất kỳ quyền truy cập dữ liệu DB nào.

UDF hoặc Thủ tục lưu trữ để đọc dữ liệu có thể kiểm tra trước để xem liệu bộ sưu tập có 0 mục hay không và nếu có thì sẽ bỏ qua. Bạn thậm chí có thể theo dõi thời gian nó đã được điền và nếu đã quá X phút (hoặc một cái gì đó tương tự), sau đó xóa và điền lại ngay cả khi có các mục trong bộ sưu tập. Nhưng bỏ qua dân số sẽ giúp ích vì nó sẽ cần phải được thực thi thường xuyên để đảm bảo rằng nó luôn được điền cho hai UDF chính để lấy các giá trị từ đó.

Mối quan tâm chính là khi SQL Server quyết định dỡ bỏ Miền ứng dụng vì bất kỳ lý do gì (hoặc nó được kích hoạt bởi một cái gì đó sử dụng DBCC FREESYSTEMCACHE('ALL');). Bạn không muốn mạo hiểm rằng bộ sưu tập bị xóa giữa việc thực thi UDF "dân cư" hoặc Thủ tục lưu trữ và UDF để có được các giá trị "năm" và "tháng". Trong trường hợp đó, bạn có thể kiểm tra ngay từ đầu hai UDF đó để đưa ra một ngoại lệ nếu bộ sưu tập trống, vì sẽ tốt hơn là lỗi hơn là cung cấp kết quả sai.

Tất nhiên, mối quan tâm được lưu ý ở trên giả định rằng mong muốn là có hội được đánh dấu là SAFE. Nếu hội có thể được đánh dấu là EXTERNAL_ACCESS, thì có thể có một hàm tạo tĩnh thực thi phương thức đọc dữ liệu và điền vào bộ sưu tập, do đó bạn chỉ cần thực hiện thủ công để làm mới các hàng, nhưng chúng sẽ luôn được điền (bởi vì hàm tạo của lớp tĩnh luôn chạy khi lớp được tải, điều này xảy ra bất cứ khi nào một phương thức trong lớp này được thực thi sau khi khởi động lại hoặc Miền ứng dụng không được tải). Điều này đòi hỏi phải sử dụng kết nối thông thường và không phải là Kết nối bối cảnh trong quá trình (không có sẵn cho các nhà xây dựng tĩnh, do đó cần phải có EXTERNAL_ACCESS).

Xin lưu ý: để không bắt buộc phải đánh dấu Hội là UNSAFE, bạn cần đánh dấu bất kỳ biến lớp tĩnh nào là readonly. Điều này có nghĩa, ít nhất, bộ sưu tập. Đây không phải là vấn đề vì các bộ sưu tập chỉ đọc có thể có các mục được thêm hoặc xóa khỏi chúng, chúng không thể được khởi tạo bên ngoài hàm tạo hoặc tải ban đầu. Theo dõi thời gian mà bộ sưu tập đã được tải cho mục đích hết hạn sau X phút là khó khăn hơn vì một static readonly DateTimebiến lớp không thể thay đổi bên ngoài hàm tạo hoặc tải ban đầu. Để khắc phục hạn chế này, bạn cần sử dụng một bộ sưu tập chỉ đọc tĩnh có chứa một mục duy nhất là DateTimegiá trị để có thể xóa và thêm lại khi làm mới.


Không biết tại sao ai đó đánh giá thấp điều này. Mặc dù không chung chung, tôi nghĩ nó có thể được áp dụng trong trường hợp hiện tại của tôi. Tôi muốn có giải pháp SQL thuần túy, nhưng tôi chắc chắn sẽ xem xét kỹ hơn về vấn đề này và thử xem có hoạt động không
Roman Pekar

@RomanPekar Không chắc chắn, nhưng có rất nhiều người có khả năng chống SQLCLR. Và có lẽ một số ít chống tôi ;-). Dù bằng cách nào, tôi không thể nghĩ tại sao giải pháp này không hiệu quả. Tôi hiểu sở thích của T-SQL thuần túy, nhưng tôi không biết làm thế nào để điều đó xảy ra và nếu không có câu trả lời cạnh tranh, thì có lẽ không ai khác làm điều đó. Tôi không biết nếu các bảng được tối ưu hóa bộ nhớ và các UDF được biên dịch tự nhiên sẽ làm tốt hơn ở đây. Ngoài ra, tôi chỉ cần thêm một đoạn với một số lưu ý thực hiện để ghi nhớ.
Solomon Rutzky

1
Tôi chưa bao giờ bị thuyết phục hoàn toàn rằng việc sử dụng readonly staticslà an toàn hay khôn ngoan trong SQLCLR. Tôi đã bị thuyết phục ít hơn sau đó sẽ tiếp tục đánh lừa hệ thống bằng cách biến nó readonlythành một kiểu tham chiếu, sau đó bạn đi và thay đổi . Cung cấp cho tôi các ý chí tuyệt đối tbh.
Paul White 9

@PaulWhite Hiểu, và tôi nhớ điều này sắp diễn ra trong cuộc trò chuyện riêng tư nhiều năm trước. Do tính chất chia sẻ của Miền ứng dụng (và do đó là staticcác đối tượng) trong SQL Server, vâng, có rủi ro cho các điều kiện chủng tộc. Đó là lý do tại sao lần đầu tiên tôi xác định từ OP rằng dữ liệu này là tối thiểu và ổn định và tại sao tôi đủ điều kiện phương pháp này là yêu cầu "hiếm khi thay đổi" và đưa ra một phương thức làm mới khi cần. Trong này trường hợp sử dụng tôi không thấy nhiều nếu có rủi ro. Tôi đã tìm thấy một bài đăng cách đây nhiều năm về khả năng cập nhật các bộ sưu tập chỉ đọc theo thiết kế (trong C #, không có thảo luận lại: SQLCLR). Sẽ cố gắng tìm nó.
Solomon Rutzky

2
Không cần, không có cách nào bạn sẽ làm tôi thoải mái với điều này ngoài tài liệu chính thức của SQL Server nói rằng nó ổn, điều mà tôi khá chắc chắn là không tồn tại.
Paul White 9
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.