Author: huulinhcvp

  • Cách Grab sử dụng DynamoDB để xử lý hàng triệu đơn hàng mỗi ngày

    Cách Grab sử dụng DynamoDB để xử lý hàng triệu đơn hàng mỗi ngày

    Nội dung

    Giới thiệu

    Trong thực tế, sau khi một khách hàng đặt một đơn hàng GrabFood từ ứng dụng Grab, đối tác bán hàng sẽ chuẩn bị đơn hàng. Một đối tác tài xế (Grab Bike) sẽ tiếp nhận đơn hàng và vận chuyển cho khách hàng. Làm cách nào mà nền tảng đặt hàng (Order Platform) có thể xử lý hàng triệu đơn hàng như vậy mỗi ngày !?

    Nhìn chung, có thể phân loại các truy vấn mà Order Platform cần xử lý làm 2 loại: transactional queries vs analytical queries. Các truy vấn transactional ví dụ như: Create đơn hàng, Update đơn hàng, Get đơn hàng theo ID, Get các đơn hàng đang được xử lý của một khách hàng,… Những truy vấn này là những truy vấn quan trọng cần xử lý chính xác, hiệu quả. Các truy vấn analytical ví dụ như: Get lịch sử đơn hàng, Get các metrics thống kê về đơn hàng,…

    → Order Platform cần được thiết kế để có thể xử lý một lượng lớn dữ liệu transaction hàng tháng.

    Sau khi phân loại các mẫu truy vấn như trên, nhóm Kỹ sư của Grab đã đặt ra 3 mục tiêu thiết kế hệ thống Database để lưu trữ và xử lý như sau:

    • Stability (Tính ổn định): Giải pháp cơ sở dữ liệu phải đảm bảo phục vụ được khả năng đọc/ghi với thông lượng cao (Queries per Second, hay QPS). Xử lý đơn hàng trực tuyến phải có tính sẵn sàng cao.
    • Scalability and cost (Khả năng mở rộng và chi phí): Giải pháp phải đáp ứng được sự phát triển nhanh chóng về mặt business, ngoài ra giải pháp phải hiệu quả về chi phí trên quy mô lớn.
    • Consistency (Tính nhất quán): strong consistency cho các đối với các transactional querieseventually consistency với các analytical queries.

    Với các mục tiêu đó, Grab team đã quyết định thiết kế giải pháp cơ sở dữ liệu sử dụng các loại Database khác nhau, một để xử lý các transactional queries (OLTP) và một để xử lý các analytical queries (OLAP).

    Hình 1: Tổng quan giải pháp cơ sở dữ liệu cho Order Platform

    Vì các mục đích sử dụng khác nhau nên các cơ sở dữ liệu OLTP và OLAP sẽ lưu trữ data theo các cách khác nhau, với cùng một nguồn data nhưng OLTP sẽ lưu trữ chúng trong thời gian ngắn, ngược lại thì OLAP sẽ lưu trữ data trong thời gian dài hơn nhằm truy xuất các thông tin lịch sử và thống kê với dữ liệu. Trong Hình 1, Grab team sử dụng một Data Ingestion pipeline để có thể lưu trữ giữ liệu nhất quán trong cả 2 cơ sở dữ liệu. Cần lưu ý rằng, pipeline này sẽ hoạt động bằng cách sử dụng các phương pháp xử lý asynchronous (bất đồng bộ) vì chúng ta không cần phải xử lý các truy vấn với OLAP trực tuyến, real-time như OLTP.

    Trong bài viết này, tôi sẽ tập trung vào phân tích cách Grab xử lý hàng triệu đơn hàng mỗi ngày, chính vì vậy mà trong các phần tiếp theo, tôi sẽ không đề cập đến cơ sở dữ liệu OLAP. Các bạn nếu có hứng thú, có thể tìm đọc thêm các tài liệu từ Grab Tech Blog.

    Với các truy vấn OLTP, Grab team chọn DynamoDB là công nghệ lưu trữ và xử lý dữ liệu. Trong phần 2, tôi sẽ phân tích tại sao DynamoDB lại phù hợp. Phần 3, tôi sẽ phân tích Grab sử dụng DynamoDB như thế nào trong thiết kế mô hình dữ liệu hiệu quả.

    Tại sao lại là DynamoDB?

    Để trả lời câu hỏi này, tôi sẽ phân tích tính phù hợp của DynamoDB đối với việc đáp ứng các mục tiêu thiết kế được nêu ở phần trước.

    Điều đầu tiên để nói về DynamoDB, nó là một cơ sở dữ liệu NoSQL, fully-managed được cung cấp bởi AWS, có tính khả dụng cao (high availability), hiệu năng cao (high performance) với hiệu suất chỉ 1 phần nghìn giây (single-digit millisecond performance), khả năng mở rộng quy mô vô hạn mà không giảm hiệu suất (infinite scaling with no performance degradation) → Stability.

    High availability

    DynamoDB tự động phân phối dữ liệu và lưu lượng truy cập qua một số lượng máy chủ vừa đủ để xử lý các yêu cầu lưu trữ và thông lượng trong khi vẫn đảm bảo hiệu suất nhanh chóng và nhất quán. Tất cả dữ liệu được lưu trữ sử dụng ổ đĩa SSD và tự động được replicated qua các AZs khác nhau → high availability & high durability. Hơn nữa, chúng ta có thể sử dụng Global Tables để đồng bộ dữ liệu qua các Regions.

    Data models

    Trước khi nói về hiệu suất, tôi đề cập một chút về các mô hình dữ liệu mà DynamoDB hỗ trợ. Trong DynamoDB, có 2 mô hình dữ liệu đó là key-value store (sử dụng hash table) → cho phép hiệu suất truy vấn O(1) và wide-column store (sử dụng B-tree) → cho phép hiệu suất truy vấn O(log(n)), n là kích thước của 1 collections các phần tử có chung partition key.

    DynamoDB cho phép truy cập dữ liệu theo nhiều cách (access patterns) khác nhau với hiệu suất cao → điều quan trọng với DynamoDB đó là chúng ta phải thiết kế mô hình dữ liệu hiệu quả, tôi sẽ tiếp tục đề cập đến điều này trong phần 3 – Grab đã sử dụng DynamoDB thế nào?

    Infinite scaling with no performance degradation

    DynamoDB có khả năng scale vô hạn là nhờ cơ chế sharding dữ liệu qua nhiều server instances. Partitions là đơn vị lưu trữ cốt lõi của các DynamoDB tables. Khi một HTTP request (ứng dụng giao tiếp với DynamoDB qua HTTP connection thay vì TCP connection như các cơ sở dữ liệu truyền thống khác) tới DynamoDB, request router sẽ lấy partition key trong request đó và áp dụng một hàm băm (hash function) đối với partition key này. Kết quả của hàm băm sẽ chỉ định server nơi mà dữ liệu được lưu trữ, request sau đó sẽ được chuyển tiếp đến server nơi lưu trữ dữ liệu để thực hiện read/write với dữ liệu. Thiết kế này giúp cho DynamoDB có thể bổ sung thêm các storage nodes vô hạn khi dữ liệu scale up.

    Trong các phiên bản sớm hơn của DynamoDB, thông lượng được chia sẻ đều giữa các partitions → có thể dẫn đến mất cân bằng truy cập dữ liệu (có partition thì cần truy cập nhiều → không đủ tài nguyên, có partition thì nhu cầu truy cập ít hơn → thừa tài nguyên). Tuy nhiên, DynamoDB team hiện nay đã cho phép adaptive capacity, có nghĩa là thông lượng sẽ tự động được đáp ứng đủ cho các items cần truy cập nhiều, DynamoDB tự động cung cấp capacity cao hơn cho các partitions có nhiều lưu lượng truy cập hơn → DynamoDB có thể đáp ứng thông lượng lên đến 1000 WCUs (write capacity units) và 3000 RCUs (read capacity units). Hay nói một cách khác, DynamoDB có thể đáp ứng đến tối đa 3000 read Queries per second1000 write Queries per Second → hiệu suất cao.

    Ngoài ra, AWS còn cung cấp một tính năng nữa cho DynamoDB, gọi là DynamoDB Accelerator (DAX) – một fully-managed in-memory cache cho bảng DynamoDB. Mặc dù hiệu suất của DAX có thể không thực sự mạnh như Redis nhưng DAX có thể là một giải pháp caching phù hợp cho phép tốc độ đủ nhanh, ít bảo trì và tiết kiệm chi phí hơn so với các bộ đệm khác.

    Tóm lại, với DynamoDB, chúng ta vẫn có thể đảm bảo single-digit millisecond performance khi dữ liệu scale up. Chúng ta có thể mở rộng dữ liệu lên đến 10TB mà vẫn không suy giảm hiệu suất như sử dụng với 10GB. Điều mà không thể được đáp ứng trong các cơ sở dữ liệu quan hệ – ở đó, hiệu suất giảm dần khi dữ liệu scale up.

    Consistency

    Khi nói về các databases và các hệ thống phân tán, một trong những thuộc tính cần xem xết đó là các cơ chế consistency mà nó chúng hỗ trợ.

    Với DynamoDB, có 2 tùy chọn consistency mà cơ sở dữ liệu này hỗ trợ:

    • Strong consistency
    • Eventual consistency

    Với strong consistency, bất kỳ item nào chúng ta đọc từ DynamoDB sẽ phản ánh tất cả các yêu cầu writes đã xảy ra trước đó, trước khi yêu cầu đọc được thực thi. Ngược lại, với eventual consistency thì các yêu cầu đọc items có thể không phản ánh tất cả các yêu cầu writes xảy ra trước đó.

    → Các transactional queries trong Order Platform hoàn toàn có thể được sử dụng với strong consistency trong DynamoDB.

    Cost effective

    DynamoDB cung cấp một mô hình định giá linh hoạt. Với hầu hết các cơ sở dữ liệu, chi phí dựa trên 1 lượng capacities nhất định cho máy chủ bao gồm CPU, RAM, Hard Disks,… → điều này không tối ưu vì chúng ta sẽ phải trả tiền cho tài nguyên thay vì trả cho workload ta sử dụng (bao nhiêu truy vấn mỗi giây/queries per second)

    Ngược lại, DynamoDB được định giá trực tiếp dựa trên workload mà ứng dụng cần, chúng ta chỉ định thông lượng dựa trên WCU (write capacity units) và RCU (read capacity units). Một RCU cung cấp 1 strongly-consistent read per second hoặc 2 eventually-consistent reads per second, lên đến 4KB kích thước. Một WCU cho phép 1 write per second, lên đến 1KB kích thước → Chi phí cho Read và Write là riêng biệt → Có thể tối ưu 1 trong 2 độc lập dựa trên nhu cầu ứng dụng.

    Điều thú vị với DynamoDB là chúng ta có thể điều chỉnh thông lượng WCU và RCU nếu cần. Ví dụ như vào ban đêm hoặc cuối tuần, chúng ta có thể giảm thông lượng để tiết kiệm chi phí.

    Nếu chúng ta không estimate được thông lượng của ứng dụng → DynamoDB cung cấp tùy chọn On-Demand capacity mode (chi phí cao hơn Provisioned capacity mode) → Có thể sử dụng On-Demand capacity mode trong quá trình phát triển, thực thi load testing để đưa ra Provisioned capacity phù hợp → tiết kiệm chi phí.

    Năm 2017, AWS công bố một tính năng gọi là Time-to-live (TTL) cho DynamoDB, cho phép DynamoDB tự động xóa các item khi chúng ta chỉ muốn lưu trữ chúng cho 1 thời gian ngắn → điều này phù hợp với nhu cầu sử dụng DynamoDB của Grab, khi mà Grab team đã thiết kế một Data Ingestion Pipeline để lưu trữ dữ liệu lâu dài trong cơ sở dữ liệu OLAP. Việc lưu trữ dữ liệu trong DynamoDB trong thời gian ngắn còn cho phép hiệu quả chi phí cũng như đảm bảo hiệu suất luôn ổn định cao → đáp ứng được các transactional queries hàng ngày.

    Sử dụng DynamoDB như thế nào?

    Single-table design

    Khi mô hình hóa dữ liệu với DynamoDB, chúng ta nên sử dụng càng ít bảng càng tốt, lý tưởng nhất là chỉ sử dụng 1 bảng cho toàn bộ ứng dụng → single-table design. Tại sao lại như vậy !?

    Với các cơ sở dữ liệu quan hệ, thông thường chúng ta sẽ tạo 1 bảng ứng với mỗi entity (thực thể) trong ứng dụng. Các bảng có thể liên kết với nhau thông qua foreign keys → qua đó dữ liệu có thể được truy vấn qua các bảng sử dụng các phép toán joins. Các phép joins này khá tốn kém, khi mà nó phải scan nhiều bảng, so sánh các giá trị,… và trả về các kết quả.

    DynamoDB được xây dựng cho các use cases với hiệu suất truy vấn cao, quy mô dữ liệu lớn, như Order Platform của Grab → Các phép toán joinskhông phù hợp với các use cases như vậy. Thay vì cải thiện các phép toán joins trên quy mô dữ liệu lớn, DynamoDB giải quyết vấn đề bằng cách loại bỏ hoàn toàn việc sử dụng joins trong truy vấn. Loại bỏ bằng cách nào !?

    Cơ chế pre-join trong DynamoDB có thể cho phép liên kết dữ liệu tương tự như các cơ sở dữ liệu quan hệ. Cơ chế này sử dụng các item collections. Một item collection là tập các items trong bảng chia sẻ chung partition key. Như vậy, nhờ pre-join mà DynamoDB cho phép thiết kế cơ sở dữ liệu ứng dụng chỉ với 1 bảng. Tất nhiên, có những hạn chế nhất định với single-table design, tuy nhiên trong phạm vi bài viết này tôi sẽ không đi sâu vào phân tích những hạn chế đó. Trong các phần sau, tôi muốn làm nổi bật thiết kế của Grab sử dụng single-table design như thế nào để xử lý các transactional queries với các đơn hàng.

    Data modeling

    Trong DynamoDB, khi thiết kế mô hình dữ liệu, chúng ta phải xác định Primary keys của bảng. DynamoDB cung cấp 2 loại Primary keys khác nhau:

    • Simple primary keys: chỉ bao gồm một thuộc tính, gọi là partition key.
    • Composite primary keys: là sự kết hợp của partition key và một thuộc tính khác, gọi là sort key. Đôi khi, chúng ta có thể gọi partition key là hash key, sort key là range key.

    Để thiết kế cơ sở dữ liệu hiệu quả, trước hết chúng ta cần hình dung về mô hình dữ liệu của Order Platform. Order Platform cần thực hiện các truy vấn với đơn hàng (order), các đơn hàng liên quan đến khách hàng hay người đặt hàng, mỗi đơn hàng sẽ có 1 trong 3 trạng thái: ongoing, completed, hoặc canceled → Khá đơn giản và phù hợp với single-table design trong DynamoDB. Hình dưới đây cho thấy thiết kế này của Grab.

    Hình 2: Minh họa bảng DynamoDB lưu trữ các đơn hàng của Order Platform

    Trong hình 2 ở trên, Grab team đã đưa ra một minh họa về bảng DynamoDB nơi mà lưu trữ các đơn hàng, với Partition keymã đơn hàng (order_id) (Lưu ý rằng hình trên chỉ mang tính chất minh họa, không phải là toàn bộ thiết kế của Order Platform).

    Thiết kế này cho phép các transactional queries với đơn hàng như Get, Create, Update được thực hiện với hiệu suất cao, strong consistency. Bởi cấu trúc dữ liệu sử dụng hash table → các truy vấn sử dụng order_id sẽ có độ phức tạp thời gian là O(1).

    Với các truy vấn liên quan đến 1 đơn hàng cụ thể, chúng ta có thể sử dụng order_id (partition key). Vậy với các truy vấn liên quan đến 1 khách hàng, ví dụ như lấy danh sách các đơn hàng ở trạng thái ongoing của khách hàng Alice như trong Hình 2, chúng ta có thể tiếp tục sử dụng mô hình dữ liệu trên không !?

    → Câu trả lời là có. Đến đây, chúng ta phải sử dụng một tính năng hữu ích khác được cung cấp bởi DynamoDB, đó là Global secondary index.

    Secondary indexes

    Primary keys có thể giới hạn các access patterns, như trong trường hợp của Grab, khi sử dụng primary keys với partition keyorder_id thì chúng ta không thể tiếp tục truy vấn với 1 khách hàng cụ thể trong bảng đơn hàng. Để giải quyết hạn chế này, DynamoDB đưa ra một khái niệm gọi là secondary indexes. Secondary indexes cho phép chúng ta re-shape lại dữ liệu sang 1 cấu trúc khác để phục vụ các truy vấn với access pattern khác so với thiết kế ban đầu.

    Khi tạo secondary index, chúng ta cần chỉ định key schema của index, key schema này tương tự như Primary keys của bảng, có thể chỉ cần partition key hoặc kết hợp partition key và sort key. Có 2 loại secondary indexes trong DynamoDB:

    • Local secondary indexes (LSI)
    • Global secondary indexes (GSI)

    Một LSI sử dụng chung partition key như primary key của bảng nhưng với một sort key khác đi → điều này cho phép các truy vấn theo range khác với access pattern ban đầu.

    Ngược lại, với GSI, chúng ta có thể chọn bất cứ thuộc tính nào làm partition key và sort key.

    → Trong trường hợp của Grab, với thiết kế ban đầu như Hình 2, bởi LSI không thay đổi partition key → không phù hợp với các truy vấn theo 1 khách hàng cụ thể → Grab team đã sử dụng GSI để giải quyết vấn đề này.

    Ví dụ với một truy vấn Get tất cả các đơn hàng trong trạng thái Ongoing của khách hàng có pax_idAlice. Nếu chúng ta sử dụng GSI chỉ với partition keypax_id thì việc truy vấn dựa trên pax_id (trường hợp này là Alice) sẽ trả về tất cả các đơn hàng của Alice → Filter trên các kết quả đó để trích xuất ra các đơn hàng của Alice có trạng thái Ongoing. Như vậy, nếu như Alice có nhiều đơn hàng thì việc Filter như vậy sẽ ảnh hưởng đến độ trễ truy vấn nói riêng và hiệu năng tổng thể của hệ thống nói chung. Giải quyết bằng cách nào !?

    → Sử dụng Sparse indexes

    Hình 3: GSI cho bảng đơn hàng

    GSI được Grab team thiết kế sử dụng ID của khách hàng (pax_id_gsi) làm partition key và thời gian tạo đơn hàng (created_at) làm sort key, sparse index cho phép tại bất kỳ thời điểm nào, bảng GSI chỉ lưu trữ các đơn hàng với trạng thái Ongoing (Khi một đơn hàng chuyển từ Ongoing → Completed thì nó sẽ tự động bị xóa khỏi GSI) → Cho phép độ trễ truy vấn thấp, hiệu suất tốt hơn và hiệu quả chi phí.

    Một vài lưu ý khi sử dụng GSI với DynamoDB:

    • Với GSI, chúng ta cần provision thêm throughput. RCU và WCU được sử dụng riêng biệt so với bảng ban đầu.
    • Dữ liệu được replicated từ bảng ban đầu sang GSI theo cơ chế asynchronous → chỉ cho phép eventual consistency.

    Time to live (TTL)

    Vì mục đích chỉ lưu trữ giữ liệu trong DynamoDB trong một thời gian ngắn (chúng ta có thể thấy điều này trên ứng dụng Grab Food, các đơn hàng sau một thời gian ~ 3 tháng sẽ không còn thấy trong lịch sử nữa :v) → Grab team sử dụng tính năng TTL được cung cấp bởi DynamoDB → cho phép tự động xóa các items khi expired. Để sử dụng TTL hiệu quả, Grab team chỉ thêm TTL với các items mới được add vào bảng, xóa các items không có thuộc tính TTL theo cách thủ công và chạy các script để xóa các items có TTL đã out of date khá lâu → cho phép tính năng TTL trên bảng với kích thước đủ nhỏ → hiệu quả khi DynamoDB tự động thực hiện scan bảng và xóa các items đã expired.

    Data ingestion pipeline

    Hình 4: Data ingestion pipeline

    Grab team sử dụng Kafka để đồng bộ dữ liệu giữa 2 cơ sở dữ liệu OLTP và OLAP. Sử dụng Kafka như thế nào, các bạn có thể đọc thêm từ Grab tech blog như trong tài liệu tham khảo [1]. Trong phạm vi bài viết này, tôi không phân tích về lựa chọn này. Tuy nhiên, nếu trong tương lai có thể, tôi muốn phân tích về tính khả thi của giải pháp sử dụng DynamoDB Streams để xử lý và đồng bộ dữ liệu giữa các cơ sở dữ liệu OLTP và OLAP.

    Kết luận

    Trong bài viết này, tôi đã dựa trên một case study là Order Platform của Grab Food để phân tích một số tính năng, nguyên lý của DynamoDB phù hợp với các ứng dụng cần OLTP. DynamoDB đã cho phép Order Platform đạt được tính ổn định, tính khả dụng cao, hiệu suất cao cùng với khả năng mở rộng quy mô vô hạn. Ngoài ra, tôi cũng giới thiệu các cơ chế để cải thiện hiệu suất, chi phí hiệu quả mà Grab team sử dụng như GSI, TTL. Data ingestion pipeline được giới thiệu như là 1 biện pháp mà Grab sử dụng để đồng bộ dữ liệu sang cơ sở dữ liệu OLAP phục vụ các truy vấn thống kê cũng như đảm bảo khả năng mở rộng nghiệp vụ ứng dụng về sau.

    Tài liệu tham khảo

    [1] Xi Chen, Siliang Cao, “How we store and process millions of orders daily”, https://engineering.grab.com/how-we-store-millions-orders, Accessed: 2022-03-31

    [2] Alex DeBrie, “The DynamoDB Book”

    [3] Amazon DynamoDB, https://disaster-recovery.workshop.aws/en/services/databases/dynamodb.html, Accessed: 2022-03-31

  • Bài toán tìm kiếm tương tự

    Bài toán tìm kiếm tương tự

    Danh mục nội dung

    Tìm kiếm KNN

    Thuật toán K-hàng xóm gần nhất, còn được gọi là KNN hoặc k-NN, là một thuật toán học máy có giám sát, không có tham số, được dùng trong phân loại dữ liệu. Thuật toán này sử dụng khoảng cách để phân loại hoặc dự đoán về một nhóm điểm dữ liệu, dựa trên giả định rằng các điểm dữ liệu tương tự sẽ gần nhau về khoảng cách.

    Ngày nay, tìm kiếm hàng xóm gần nhất đã trở thành một chủ đề nghiên cứu nóng bởi tính ứng dụng thực tiễn của nó, nhằm giúp người dùng có thể dễ dàng tìm thấy thông tin họ đang tìm kiếm trong thời gian hợp lý. Thuật toán k-NN là một trong những kỹ thuật giúp tìm chính xác các hàng xóm gần nhất, bởi nó so sánh khoảng cách của mỗi điểm dữ liệu với mọi điểm dữ liệu khác, vì vậy nó yêu cầu thời gian truy vấn tuyến tính (kích thước tập dữ liệu). Nhưng thật không may, hầu hết các ứng dụng hiện đại ngày nay đều có tập dữ liệu khổng lồ (hàng triệu) với chiều cao (hàng trăm hoặc hàng nghìn), vì vậy mà tìm kiếm tuyến tính sẽ tốn kém thời gian. Hãy thử tưởng tượng một thị trường C2C trong thế giới thực với hàng triệu sản phẩm có trong cơ sở dữ liệu và có thể có hàng nghìn sản phẩm mới được tải lên mỗi ngày. So sánh từng sản phẩm với tất cả hàng triệu sản phẩm là lãng phí và mất quá nhiều thời gian, có thể nói giải pháp này là không thể mở rộng. Ngoài ra, các ứng dụng hiện đại còn có nhiều ràng buộc bổ sung khác như mức tiêu thụ bộ nhớ hợp lý và/hoặc độ trễ thấp.

    Điều quan trọng cần lưu ý là mặc dù đã có rất nhiều tiến bộ gần đây về chủ đề này, nhưng k-NN vẫn là phương pháp khả dụng duy nhất để đảm bảo truy xuất chính xác hàng xóm gần nhất.

    Tìm kiếm ANN

    Để giải quyết những vấn đề của k-NN, một lớp các thuật toán mới ra đời có tên là ANN (Approximate Nearest Neighbors). Các thuật toán ANN đánh đổi độ chính xác để mang lại hiệu quả tìm kiếm nhanh chóng, trong thời gian chấp nhận được. Trong một thị trường C2C thực tế, nơi mà số lượng hàng xóm thực tế cao hơn K hàng xóm gần nhất cần tìm kiếm rất nhiều, ANN có thể cho phép đạt được độ chính xác đáng kể khi so sánh với KNN, trong một thời gian ngắn.

    Trong các ứng dụng hiện đại, sai số nhỏ về độ chính xác đổi lại với độ trễ thấp mang lại nhiều lợi ích cho người dùng. Hai ví dụ dưới đây cho thấy điều đó:

    • Tìm kiếm trực quan – Là một người dùng, nếu tôi đang muốn tìm kiếm một bức ảnh về chiếc giày yêu thích, tôi sẽ không bận tâm đến thứ tự xuất hiện của các kết quả trả về, tôi có thể thỏa mãn nhu cầu tìm kiếm của mình nếu như một số ít kết quả mong muốn được hiển thị gần nhất trong khung nhìn của mình.
    • Các hệ gợi ý – Tương tự như trên, tôi cũng không bận tâm quá nhiều đến thứ tự ưu tiên của các kết quả gần nhất khi mà tôi chỉ cần khoảng 8 đến 10 kết quả tương tự hiển thị trong khung nhìn của mình.

    Các kỹ thuật ANN tăng tốc độ tìm kiếm bằng cách tiền xử lý dữ liệu thành một chỉ mục hiệu quả và thường được xử lý qua các giai đoạn sau:

    • Vector Transformation – được áp dụng trên các véc-tơ trước khi chúng được lập chỉ mục, ví dụ như giảm chiều dữ liệu.
    • Vector Encoding – được áp dụng trên các véc-tơ để xây dựng chỉ mục thực sự cho tìm kiếm; một số kỹ thuật dựa trên cấu trúc dữ liệu được áp dụng như: Cây, LSH, và lượng tử hóa – một kỹ thuật để mã hóa véc-tơ thành dạng nén, nhỏ gọn hơn nhiều.
    • Thành phần loại bỏ tìm kiếm toàn bộ – được áp dụng trên các véc-tơ để tránh tìm kiếm toàn bộ như k-NN diễn ra, sử dụng các kỹ thuật như: Các tệp đảo ngược, các đồ thị hàng xóm lân cận,…

    Vì sự hữu ích cũng như ứng dụng thực tiễn mà ANN mang lại, nên hiện nay đã có một số thuật toán ANN được triển khai nguồn mở và được sử dụng phổ biến, như: Annoy của Spotify [1], ScaNN của Google [2], Faiss của Facebook [3], và Hnsw [4].

    Tuy nhiên, các kỹ thuật ANN cũng tồn tại nhược điểm, một trong số đó là tài nguyên điện toán, cụ thể là RAM, các kỹ thuật này phải tải toàn bộ các véc-tơ vào RAM để có thể truy xuất các hàng xóm gần nhất.

    Tài liệu tham khảo

    [1] ANNOY library, https://github.com/spotify/annoy

    [2] ScaNN library, https://github.com/google-research/google-research/tree/master/scann

    [3] Faiss library, https://github.com/facebookresearch/faiss

    [4] Hnsw library, https://github.com/nmslib/hnswlib

    Author

    Ha Huu Linh

  • Một số nguyên lý của Elasticsearch

    Một số nguyên lý của Elasticsearch

    Elasticsearch là một công cụ phân tích và tìm kiếm phân tán theo thời gian thực. Nó cho phép khám phá dữ liệu với tốc độ và quy mô chưa từng có trước đây. Nó được sử dụng trong tìm kiếm toàn văn bản (full-text search), tìm kiếm có cấu trúc, phân tích và kết hợp cả ba. Một số hệ thống nổi tiếng sử dụng Elasticsearch có thể kể đến như: GitHub, StackOverflow, Wikipedia,… Elasticsearch còn là một công cụ tìm kiếm nguồn mở, được xây dựng dựa trên Apache Lucene – một thư viện tìm kiếm toàn văn bản.

    Elasticsearch lưu trữ các documents theo mô hình phân tán, các documents là các đối tượng lưu trữ dữ liệu dưới dạng khóa-giá trị (key-value), có thể được chuyển đổi từ định dạng JSON, và thực tế là Elasticsearch nhận các JSON documents để làm đầu vào cho xử lý hoặc để trả về các kết quả cho máy khách. Elasticsearch không chỉ lưu trữ các documents, nó còn lập chỉ mục (indexing) chúng để làm cho chúng có thể tìm kiếm được.

    Elasticsearch cung cấp khả năng mở rộng và tính khả dụng cao nhờ bản chất phân tán, nhờ che giấu toàn bộ việc quản lý hạ tầng giúp cho ứng dụng có thể dễ dàng làm việc với Elasticsearch mà không cần phải có một hiểu biết sâu sắc về vận hành hạ tầng cũng như tổ chức dữ liệu trong Elasticsearch. Tuy nhiên, để có vể vận hành Elasticsearch hiệu quả, việc hiểu cách Elasticsearch tổ chức dữ liệu là cần thiết. Về mặt vật lý, Elasticsearch tổ chức dữ liệu theo 3 cấp độ, với các khái niệm: cluster, node, và shard.

    Một node là một instance đang chạy của Elasticsearch; trong khi đó một cluster bao gồm 1 hoặc nhiều nodes phối hợp để chia sẻ dữ liệu và workload. Khi một node được thêm vào hoặc loại bỏ khỏi cluster thì cluster sẽ tự tổ chức lại để có thể điều phối dữ liệu đều giữa các nodes. Để đạt được điều đó thì một node trong cluster sẽ được chọn làm master node – đóng vai trò như người quản lý, nó chịu trách nhiệm cho việc quản lý các thay đổi trong cluster như thêm hoặc xóa một node khỏi cluster. Master node không tham gia vào các thay đổi hoặc tìm kiếm ở cấp độ document trừ phi cluster chỉ có 1 node duy nhất, việc này giúp cho nó không trở thành bottleneck của hệ thống khi lưu lượng truy cập tăng lên. Trong Elasticsearch thì bất kỳ node nào cũng có thể trở thành master node. Trong khi nói về tổ chức vật lý bên trong Elasticsearch thì không thể không nói về các shards; trong Elasticsearch thì shard là đơn vị vật lý cấp thấp được sử dụng để tổ chức dữ liệu – nơi mà các documents trong một index sẽ được phân bổ trong một vài shards.

    Hình trên minh họa mối quan hệ giữa index và các shards trong Elasticsearch. Về bản chất, index chỉ là một không gian tên logic chứa các documents, các nhà phát triển phần mềm ứng dụng sẽ làm việc với index thay vì trực tiếp với các shards; trong khi đó ở phía dưới, Elasticsearch sẽ tổ chức các documents trong cùng một index vào các shards.

    Như vậy, khi làm việc với Elasticsearch, chúng ta quan tâm chính là về index. Thuật ngữ index có thể được hiểu theo nhiều nghĩa khác nhau, phụ thuộc vào ngữ cảnh: (i) index là một danh từ chỉ nơi lưu trữ các documents, nó giống như một cơ sở dữ liệu trong các cơ sở dữ liệu truyền thống; (ii index cũng có thể được hiểu là một động từ chỉ việc lưu trữ 1 document vào một index (danh từ), thường được gọi là quá trình indexing (lập chỉ mục); và (iii) inverted index chỉ một cấu trúc dữ liệu mà Elasticsearch và Lucene sử dụng để hỗ trợ truy xuất dữ liệu toàn văn bản nhanh chóng.

    Ngày nay, Elasticsearch đã được sử dụng trong rất nhiều ứng dụng nói chung và thương mại điện tử nói riêng bởi nó được thiết kế để hỗ trợ mạnh mẽ trong tìm kiếm và phân tích với dữ liệu. Elasticsearch có thể được triển khai trên các máy chủ tại chỗ (on-premise) nhưng phổ biến hơn hết là trên môi trường đám mây. Vào năm 2015, Amazon Web Services đã triển khai Elasticsearch trên đám mây AWS để cho phép các nhà phát triển phần mềm chạy và vận hành các cụm máy chủ Elasticsearch trên môi trường đám mây. Việc triển khai Elasticsearch trên môi trường đám mây AWS là cần thiết bởi nó cho phép các ứng dụng trên hệ sinh thái AWS có thể dễ dàng giao tiếp, và nhà phát triển phần mềm dễ dàng giám sát hiệu năng hệ thống nhờ sự hỗ trợ gián tiếp từ AWS Cloudwatch và nhiều dịch vụ hạ tầng khác.

    Đến năm 2021, Amazon Web Services khởi chạy dự án nguồn mở OpenSearch, như một bản sao chép từ phiên bản 7.10.2 của Elasticsearch. Và OpenSearch được phát triển, bảo trì và quản lý bởi Amazon Web Services. Dịch vụ hạ tầng từ đó cũng được đổi tên thành AWS OpenSearch.

    Author: Ha Huu Linh

  • Thiết kế hệ thống

    Thiết kế hệ thống

    Một số suy nghĩ cá nhân về thiết kế hệ thống. Bài viết này chỉ muốn tạo ra một cái nhìn High-level về thiết kế hệ thống, để người đọc có thể cảm nhận được rằng: À, thực ra các hệ thống lớn cũng… chỉ có thế thôi, ez game.

    Đầu tiên, mọi hệ thống phức tạp đều được build từ những hệ thống nhỏ hơn, còn được gọi là các hệ thống con (sub-systems). Như vậy để thiết kế được hệ thống phức tạp thì cần hiểu những hệ thống con. Để hiểu được những hệ thống con thì không cách nào khác ngoài việc tự build những hệ thống con.

    Làm cách nào để ghép các hệ thống con lại với nhau thành một hệ thống phức tạp hơn !? Để làm được điều đó, trước hết cần hiểu các giao thức mạng (Protocols), sau đó là các mô hình truyền thông điệp/giao tiếp giữa các máy chủ ứng dụng.

    Có một số mô hình giao tiếp phổ biến, hầu hết chúng ta đều tiếp cận với mô hình Synchronous trước, hay còn gọi là mô hình Request-Response. Phần lớn sinh viên đại học dừng ở mức này, nhưng như thế là đủ, bởi sinh viên cần quan tâm hơn đến các kiến thức nền tảng.

    Tuy nhiên, các hệ thống hiện nay quá phức tạp, phần lớn workloads là Asynchronous thay vì Synchronous. Chính vì vậy, việc học các mô hình giao tiếp Asynchronous là cần thiết, một số mô hình phổ biến như: Push model, Polling model,… Messgages Broker và Event-bus là hai khái niệm phổ biến trong các hệ thống xử lý Asynchronous workloads. Chúng về bản chất chỉ là 1 layer trung gian giúp de-coupling các hệ thống con với nhau.

    Sau khi nắm được sơ sơ về các building blocks rồi thì chúng ta có xu hướng thiết kế theo “khuôn mẫu” –> Architecture Patterns ra đời. Việc học Architecture Patterns có 2 ý nghĩa: (i) thứ nhất giúp chúng ta build các hệ thống phức tạp nhanh hơn bởi những mẫu sẵn có, và (ii) giúp chúng ta hiểu nhanh hơn về cách giao tiếp giữa các hệ thống con với nhau, bởi con người có xu hướng thích làm theo mẫu (có thằng khác chứng minh rồi)

    Tóm lại, nếu nắm được các building blocks kể trên thì kể ra trên đời này không có hệ thống phức tạp nào là không làm được :)))

    p/s: Bốc phét vậy thôi chứ mình vẫn đang tiếp cận ở High-level, cần nghiên cứu nhiều hơn về networking protocols ? Viết bài là một cách học của mình :))) 

  • Nghiên cứu

    Nghiên cứu

    Nghiên cứu

    Thi thoảng mình nhận được một số câu hỏi của bạn bè, trong đó có câu: Mày làm Nghiên cứu hay Ứng dụng ??

    Mình trộm nghĩ, nếu nói tao làm nghiên cứu thì chỉ đúng một phần mà nói tao làm ứng dụng thì cũng chỉ đúng được một phần. Nên tấu hài rằng: “Tao làm nghiên cứu ứng dụng”. Kể ra cũng… hợp lý ?

    Mình luôn tự coi bản thân là (hoặc cố gắng để trở thành) một Kỹ sư phần mềm, bấy lâu nay mình vẫn luôn nghĩ vậy. Và khi gặp một vấn đề kỹ thuật khó, thì một Kỹ sư bắt buộc (hoặc cần) phải có năng lực nghiên cứu giải pháp. Lấy ví dụ, mình đang cần phát triển và triển khai 1 mô hình Machine Learning (nhẹ, không cần GPU) và 1 mô hình Deep Learning (nặng, có thể cần GPU) trên môi trường AWS; yêu cầu Kỹ thuật đặt ra là tối thiểu việc vận hành hạ tầng cũng như việc Update mô hình khi cần thiết –> có rất nhiều solutions khác nhau –> cần Re-search. Một vài phân tích đơn giản như; do business mà các hệ thống này ít được sử dụng thường xuyên –> chúng ta nên dùng Serverless để tối ưu chi phí (hoặc Spot instance); với hệ thống Machine Learning, do đặc điểm nhẹ, suy diễn nhanh –> có thể sử dụng AWS Lambda –> đến đây cần nghiên cứu cách sử dụng Aws Lambda sao cho hiệu quả (bởi một số giới hạn Package size) –> cần thực nghiệm các giải pháp khác nhau: Lambda layers, Elastic File System, Docker Image,… Một ví dụ khác là thằng bạn mình loay hoay tìm cách Crawl hết sản phẩm của các trang thương mại điện tử, rồi tìm cách lưu trữ và sử dụng sao cho hiệu quả,… cũng phải mất khá nhiều thì giờ để Re-search (search đi search lại).

    Tóm lại, mình nghĩ đơn giản Nghiên cứu (hay Research) tức là “search đi search lại”. Không cứ phải phát minh ra một cái vĩ đại, chưa ai làm mới là Nghiên cứu, mà đơn giản chỉ cần giải quyết đúng bài toán mà mình gặp phải… đó cũng có thể gọi là nghiên cứu được chứ nhỉ ? Nghiên cứu thường đi kèm với năng lực phân tích vấn đề, bởi chỉ có phân tích thì chúng ta mới chỉ ra được ưu nhược điểm của giải pháp, tại sao nó lại phù hợp với bài toán của mình. Chính nhờ sự nghiên cứu mà năng lực, kỹ năng của Kỹ sư tăng lên –> qua đó sẽ có khả năng phán đoán, quyết định giải pháp tốt hơn, nhạy bén hơn –> Thu nhập cao hơn ?

    Technologies come and technologies go, but insight is forever.

  • Meditations on Computer Programming – 1

    Meditations on Computer Programming – 1

    1. Bản chất của các cấu trúc dữ liệu cơ bản (trong các Ngôn ngữ lập trình) là phục vụ cho 2 việc: Lưu trữ dữ liệu và Truy xuất dữ liệu.
    2. Để lưu trữ dữ liệu thì cần các Ô nhớ (trong Memory). Để Truy xuất dữ liệu thì cần các Địa chỉ của các Ô nhớ nói trên.
    3. Mỗi cấu trúc dữ liệu sẽ triển khai các phương pháp Lưu trữ và Truy xuất khác nhau. Array và Linked List là 2 cấu trúc dữ liệu đơn giản nhất.
    4. Hầu hết các ngôn ngữ lập trình bậc cao hiện nay đều triển khai rất nhiều cấu trúc dữ liệu hữu ích. Một trong những cấu trúc dữ liệu Quan trọng và Mạnh mẽ nhất là Dictionaries.
    5. Trong Python thì Dictionaries được áp dụng ở mọi nơi (Classes, Modules, Namespaces, Sets, Dicts,…)
    6. Tìm hiểu sâu bên trong về cách triển khai Dictionaries tức là ta đang nghiên cứu về một trong những vấn đề quan trọng nhất của Khoa học máy tính, đó là: Hashing
  • Python Deep Dive: Hiểu closures, decorators và các ứng dụng của chúng – Phần 4

    Python Deep Dive: Hiểu closures, decorators và các ứng dụng của chúng – Phần 4

    Python Deep Dive: Hiểu closures, decorators và các ứng dụng của chúng – Phần 4

    Ở bài viết này, tôi sẽ giới thiệu một kỹ thuật gọi là decorator. Nhìn chung, nếu ai đã từng làm việc với các python web framework như Django, Flask, FastAPI,… thì sẽ sử dụng kỹ thuật này thường xuyên, nhưng không phải ai cũng hiểu rõ bản chất của nó.

    Thừa nhận rằng, khi sử dụng các decorators có sẵn mà các framework cung cấp, đã đủ để chúng ta làm việc như một web developer. Tuy nhiên, việc nắm được bản chất của kỹ thuật này cũng giúp cho chúng ta sử dụng decorators hiệu quả hơn, có thể tùy chỉnh và tạo ra các decorators của riêng mình, như vậy là ta đã trở thành một lập trình viên chuyên nghiệp hơn.

    Table of contents

    Decorators

    Nhắc lại một ví dụ trong Phần 3, chúng ta đã áp dụng Closure để maintain một bộ đếm – đếm số lần gọi một hàm bất kỳ.

    def counter(fn):
        cnt = 0  # số lần chạy fn, khởi tạo là 0
    
        def inner(*args, **kwargs):
            nonlocal cnt
            cnt = cnt + 1
            print('Hàm {0} đã được gọi {1} lần'.format(fn.__name__, cnt))
            return fn(*args, **kwargs)
    
        return inner

    Hàm counter nhận một function làm đầu vào – fn. Trong hàm counter, ta khởi tạo 1 biến cục bộ cnt – biến này sẽ đến số lần gọi hàm fn, mỗi khi goị đến hàm inner thì biến cnt được tăng lên 1 đơn vị.

    Việc sử dụng *args**kwargs trong hàm inner giúp ta có thể gọi hàm fn với bất kỳ sự kết hợp positional argumentskeyword-only arguments nào. Lấy ví dụ:

    def mult(x, y=10):
        """
        return the products of two values
        """
        return x * y
    
    mult = counter(mult) # trả về inner function - closure

    Nhớ rằng, ban đầu mult là một label trỏ đến hàm mult được định nghĩa ở trên. Goị hàm counter(mult) sẽ trả về một closure và gán vào một label là mult, thì lúc này mult sẽ trỏ đến closure đó, rõ ràng là khác so với nơi mà mult ban đầu trỏ đến.

    Tuy nhiên, inner thực hiện gọi hàm mult ban đầu cho chúng ta và trả về kết quả của nó, chính vì vậy mà kết quả trả về khi gọi hàm inner không khác so với việc gọi hàm mult ban đầu. Nhưng thực sự, hàm inner đã làm thêm một số việc trước khi gọi và trả về kết quả của hàm mult, đó là đếm số lần hàm mult được gọi.

    mult(3, 5)
    # In ra: Hàm mult đã được gọi 1 lần
    # return 15

    Chúng ta đã thực sự sửa đổi hàm mult ban đầu, bằng cách wrapping nó bên trong một hàm khác – bổ sung thêm chức năng cho nó –> chúng ta đã decorated hàm mult với hàm counter và chúng ta gọi counterdecorator function.

    Nhìn chung, một decorator function sẽ:

    • lấy 1 function như một đối số
    • return một closure
    • closure nói trên sẽ nhận bất kỳ sự kết hợp đầu vào nào (sử dụng *args và **kwargs)
    • chạy một vài chức năng bên trong closure
    • closure function sẽ gọi original function sử dụng các đối số được truyền vào closure
    • return kết quả được trả về từ function call nói trên

    The @ Symbol

    Như đã thấy, chúng ta hoàn toàn có thể sử dụng decorator function như sau:

    def my_func(*args, **kwargs):
        # some code here
        # ...
    my_func = func(my_func)

    Trong đó, funcdecorator function, còn my_func là hàm được decorated.

    Tuy nhiên, có một cách khác thanh lịch hơn:

    @func
    def my_func(*args, **kwargs):
        # some code here
        # ...

    Introspection

    Sử dụng lại counter như một decorator cho hàm mult:

    import inspect
    
    @counter
    def mult(x, y=10):
        """
        return the products of two values
        """
        return x * y
    
    print(f'name = {mult.__name__}') # name = inner

    Ta thấy rằng, câu lệnh print ở trên sẽ in ra màn hình name = inner. Rõ ràng, tên của hàm mult không phải là mult nữa, mà nó là inner – tên của một closure, điều này chứng tỏ một điều rằng, hàm mult lúc này chính là một closure.

    Vì vậy, cầu lưu ý rằng, khi chúng ta decorate một function, tức là chúng ta đã làm cho function đó thay đổi (về bản chất).

    Để chắc chắn hơn, hãy thử gọi:

    print(f'signature: {inspect.signature(mult)}') # signature: (*args, **kwargs)

    Như vậy, khi gọi inspect.signature(mult), chúng ta đã nhìn thấy rõ chữ ký của hàm inner được trả về.

    Việc decorate một function làm thay đổi docstring, signature, name khiến cho việc debugging trở nên khó khăn hơn rất nhiều.

    The wraps function

    Để giải quyết vấn đề mới nói ở trên, functools module cung cấp một wraps function có thể fix metadata của inner function trong decorator. Bản thân wraps function này là một decorator.

    Hàm wraps này sẽ decorate inner function để thay đổi metadata (docstring, signature, name,…) của nó. Thêm nữa, nó phải sử dụng hàm mult như một đối số đầu vào, để có thể thay thế metadata của inner function bằng metadata của mult function (hàm được decorated)

    from functools import wraps
    import inspect
    
    def counter(fn):
        cnt = 0  # số lần chạy fn, khởi tạo là 0
    
        @wraps(fn)
        def inner(*args, **kwargs):
            nonlocal cnt
            cnt = cnt + 1
            print('Hàm {0} đã được gọi {1} lần'.format(fn.__name__, cnt))
            return fn(*args, **kwargs)
        return inner
    
    @counter
    def mult(x, y=10):
        """
        return the products of two values
        """
        return x * y
    
    print(f'name = {mult.__name__}') # name = mult
    print(f'signature: {inspect.signature(mult)}') # signature: (x, y=10)

    Chúng ta không nhất thiết phải sử dụng wraps function trong khi sử dụng decorator, nhưng việc sử dụng nó sẽ giúp cho việc debugging trở nên dễ dàng hơn.

    Summary

    1. Decorators trong Python là cú pháp cho phép một function sửa đổi một function khác tại thời gian chạy (runtime)
    2. Ký pháp @ giúp cho việc sử dụng decorator thanh lịch hơn (mặc dù có thể không cần)
    3. Hãy sử dụng wraps function giúp cho việc debugging dễ dàng hơn khi làm việc với decorators.

    References

    [1] Fred Baptiste, Python Deep Dive, Part 1

    [2] Brett Slatkin, Effective Python, Item 26

    Authors

    [email protected]

  • Python Deep Dive: Hiểu closures, decorators và các ứng dụng của chúng – Phần 3

    Python Deep Dive: Hiểu closures, decorators và các ứng dụng của chúng – Phần 3

    Trong lập trình với Python thì Functional Programming đóng một vai trò vô cùng quan trọng và các functions trong Python là các first-class citizens. Điều đó có nghĩa là chúng ta có thể vận hành các functions giống như các objects khác:

    • Truyền các function giống như các đối số.
    • Gán một function cho một biến số.
    • Return một function từ một function khác.

    Dựa trên những điều này, Python hỗ trợ một kỹ thuật vô cùng mạnh mẽ: closures. Sau khi hiểu closures, chúng ta sẽ đi đến tiếp cận một khái niệm rất quan trọng khác – decorators. Đây là 2 khái niệm/kỹ thuật mà bất kỳ lập trình viên Python chuyên nghiệp nào cũng cần phải nắm vững.

    Trong phần 3 này, tôi sẽ giới thiệu một số ví dụ ứng dụng closure để viết code hiệu quả hơn.

    Bài viết này yêu cầu kiến thức tiên quyết về scopes, namespaces, closures trong Python. Nếu bạn chưa tự tin, thì nên đọc trước 2 bài viết dưới đây (theo thứ tự):

    Table of contents

    Closure

    Nhắc lại

    Closure có thể tránh việc lợi dụng các giá trị global và cung cấp một cách thức ẩn dữ liệu (data hiding), cung cấp một giải pháp object-oriented cho vấn đề. Khi chỉ có một vài phương thức được triển khai trong một class, thì closure có thể cung cấp một giải pháp thay thế nhưng thanh lịch hơn. Khi số lượng thuộc tính và phương thức tăng lên nhiều, thì sử dụng class sẽ phù hợp hơn. Các tiêu chí sau cho thấy closure trong Python khi một nested function có tham chiếu một giá trị trong enclosing scope:

    • Tồn tại một nested function (function bên trong function khác)
    • Nested function có tham chiếu đến một giá trị được khai báo bên trong enclosing function.
    • Enclosing function trả về nested function (giá trị được return)

    Nguồn ảnh: Andre Ye

    Averager

    Trong ví dụ này, ta sẽ xây dựng một hàm tính giá trị trung bình của nhiều giá trị sử dụng closure. Hàm này có thể tính giá trị trung bình theo thời gian bằng cách thêm các đối số vào hàm đó mà không cần phải lặp lại việc tính tổng các giá trị trước đó.

    Cách tiếp cận dễ dàng nghĩ đến nhất là sử dụng class trong Python, ở đó ta sẽ sử dụng một biến instance để lưu trữ tổng của dãy số và số số hạng. Sau đó cung cấp cho class đó một method để thêm vào 1 số hạng mới, và trả về giá trị trung bình cộng của dãy số.

    class Averager:
        def __init__(self):
            self._count = 0
            self._total = 0
    
        def add(self, value):
            self._total += value
            self._count += 1
            return self._total / self._count
    
    a = Averager()
    a.add(1) # return 1.0
    a.add(2) # return 1.5
    a.add(3) # return 2.0

    Bằng cách sử dụng closure, ta có thể tận dụng functions trong python để xây dựng được tính năng tương tự việc sử dụng class, nhưng thanh lịch và hiệu quả hơn.

    def averager():
        total = 0
        count = 0
    
        def add(value):
            nonlocal total, count
            total += value
            count += 1
            return 0 if count == 0 else total / count
    
        return add
    
    a = averager()
    a(1) # return 1.0
    a(2) # return 1.5
    a(3) # return 2.0

    Counter

    Áp dụng closure, ta có thể xây dựng 1 bộ đếm, đếm số lần gọi một function mỗi khi function đó chạy. Function này có thể nhận bất kỳ đối số hoặc đối số từ khóa nào.

    def counter(fn):
        cnt = 0  # số lần chạy fn, khởi tạo là 0
    
        def inner(*args, **kwargs):
            nonlocal cnt
            cnt = cnt + 1
            print('{0} has been called {1} times'.format(fn.__name__, cnt))
            return fn(*args, **kwargs)
    
        return inner

    Giả sử ta muốn bổ sung thêm việc đếm số lần gọi hàm tính tổng 2 số:

    def add(a, b):
        return a + b

    Ta có thể áp dụng closure như sau:

    count_sum = counter(add))
    count_sum(1, 2) # sum has been called 1 times
    count_sum(3, 5) # sum has been called 2 times

    Sở dĩ hàm count_sum có thể làm được như trên là bởi vì nó đang sở hữu 2 free variables là:

    • fn: tham chiếu đến hàm add
    • cnt: duy trì đếm số lần gọi hàm fn
    count_sum.__code__.co_freevars # ('cnt', 'fn')

    Đến đây, thay vì in ra standard output số lần gọi 1 hàm bất kỳ (hàm add chỉ là 1 ví dụ), ta có thể sử dụng 1 từ điển là global variable lưu trữ các cặp {key: value}. Ở đó, key là tên của hàm và value là số lần gọi hàm. Để làm được điều đó, ta cần sửa đổi một chút ở hàm counter bằng cách bổ sung thêm cho nó 1 đối số là tham chiếu đến từ điển lưu trữ:

    def counter(fn, counters):
        cnt = 0  # số lần chạy fn, khởi tạo là 0
    
        def inner(*args, **kwargs):
            nonlocal cnt
            cnt = cnt + 1
            counters[fn.__name__] = cnt  # counters là nonlocal
            return fn(*args, **kwargs)
    
        return inner
    func_counters = dict() # khởi tạo từ điển
    # đếm số lần chạy hàm add
    counted_add = counter(add, func_counters)
    for i in range(10):
        counted_add(i, i+1)

    Biến func_counters là biến toàn cục, vì vậy ta có thể bổ sung thêm từ khóa là tên của hàm khác vào nó, thử 1 ví dụ, xét hàm nhân 2 số:

    def mult(a, b):
        return a * b
    
    counted_mult = counter(mult, func_counters)
    for i in range(7):
        counted_mult(i, i)

    Biến func_counters lúc này sẽ cho chúng ta biết số lần gọi hàm add và số lần gọi hàm mult

    func_counters ## {'mult': 7, 'add': 10}

    Cả 2 hàm counted_addcounted_mult đều đang giữ 3 free variables:

    • fn: tham chiếu đến hàm cần đếm
    • cnt: duy trì đếm số lần gọi hàm fn
    • counters: tham chiếu đến từ điển lưu trữ thông tin về số lần đếm các hàm

    Hãy thử nghĩ, nếu như, thay vì ta gọi:

    counted_add = counter(add, func_counters)

    Ta gọi như sau:

    add = counter(add, func_counters)

    Lúc này, ta có một hàm add mới, thực sự không khác hàm add lúc đầu về tính năng là tính tổng 2 số. Tuy nhiên sau khi gọi hàm add, lúc này ta còn nhận được thêm thông tin về số lần gọi hàm add được giữ trong biến func_counters.

    Như vậy, hàm counter đóng vai trò như 1 trình trang trí cho hàm add (tương tự với hàm mult), nó bổ sung thêm tính năng cho hàm add nhưng không thay đổi hành vi của hàm ađd (trả về tổng 2 số). Đây là tính chất quan trọng của decorator mà chúng ta sẽ tìm hiểu trong một bài viết sau.

    Use Closures Skilfully

    Closure là một vũ khí mạnh mẽ của Python. Người mới bắt đầu có thể gặp đôi chút khó khăn trong việc áp dụng nó trong việc viết mã. Tuy nhiên, nếu ta có thể hiểu và sử dụng nó một cách thuần thục, thì nó sẽ vô cùng hữu ích.

    Trên thực tế thì decorator trong Python là một sự mở rộng của closure. Chúng ta sẽ bàn về decorator sau, nhưng ai cũng biết rằng hầu hết các framework sử dụng Python cho web development đều sử dụng decorator rất thường xuyên.

    Dưới đây là 2 tips quan trọng sẽ giúp bạn sử dụng closure thuần thục:

    Sử dụng lambda function để đơn giản hóa code

    Xét 1 ví dụ:

    def outer_func():
        name = "Tu Anh"
    
        def print_name():
            print(name)
    
        return print_name
    
    f = outer_func()
    print(outer_func.__closure__) # None
    print(f.__closure__) # (<cell at 0x7f31445b2e90: str object at 0x7f314459c070>,)
    print(f.__closure__[0].cell_contents) # Tu Anh

    Ta có thể làm cho ví dụ trên thanh lịch hơn bằng cách sử dụng lambda function:

    def outer_func():
        name = "Tu Anh"
    
        return lambda _: print(name)
    
    f = outer_func()
    print(outer_func.__closure__) # None
    print(f.__closure__) # (<cell at 0x7f31445a44d0: str object at 0x7f31445b6070>,)
    print(f.__closure__[0].cell_contents) # Tu Anh

    Closures che giấu các biến private hiệu quả hơn

    Trong Python thì không có các từ khóa built-in như là public hay private để kiểm soát khả năng truy cập của các biến. Theo quy ước, chúng ta sử dụng double underscores để định nghĩa một member của 1 class là private. Tuy nhiên, chúng vẫn có thể được truy cập.

    Đôi khi, chúng ta cần bảo vệ mạnh mẽ hơn để ẩn một biến. Và closures có thể giải quyết vấn đề này. Như ví dụ ở trên, thì khó để ta có thể truy cập và thay đổi được giá trị của biến name trong hàm f. Như vậy, biến name dường như đã private hơn.

    References

    [1] Andre Ye, Essential Python Concepts & Structures Any Serious Programmer Needs to Know, Explained

    [2] Fred Baptiste, Python Deep Dive, Part 1

    [3] Yang Zhou, 5 Levels of Understanding Closures in Python

    Authors

    [email protected]

  • Python Deep Dive: Hiểu closures, decorators và các ứng dụng của chúng – Phần 2

    Python Deep Dive: Hiểu closures, decorators và các ứng dụng của chúng – Phần 2

    Trong lập trình với Python thì Functional Programming đóng một vai trò vô cùng quan trọng và các functions trong Python là các first-class citizens. Điều đó có nghĩa là chúng ta có thể vận hành các functions giống như các objects khác:

    • Truyền các function giống như các đối số.
    • Gán một function cho một biến số.
    • Return một function từ một function khác.

    Dựa trên những điều này, Python hỗ trợ một kỹ thuật vô cùng mạnh mẽ: closures. Sau khi hiểu closures, chúng ta sẽ đi đến tiếp cận một khái niệm rất quan trọng khác – decorators. Đây là 2 khái niệm/kỹ thuật mà bất kỳ lập trình viên Python chuyên nghiệp nào cũng cần phải nắm vững.

    Bài viết này yêu cầu kiến thức tiên quyết về scopes, namespace trong Python. Nếu bạn chưa tự tin, vui lòng đọc trước Phần 1

    Table of contents

    Free Variables and Closures

    Nhắc lại rằng: Các functions được xác định bên trong function khác có thể truy cập các biến bên ngoài (nonlocal)

    def outer():
        x = 'python'
        def inner():
            # x trỏ đến cùng một object mà biến x bên ngoài trỏ tới.
            print("{0} rocks!".format(x))
        inner()
    outer() # python rocks! --> Đây được gọi là một closure.

    Biến nonlocal x trong hàm inner được gọi là free variable. Khi chúng ta xem xét hàm inner, chúng ta thực sự đang thấy:

    • hàm inner
    • free variable x (đang có giá trị là ‘python’)

    Xin lưu ý rằng biến x trong hàm inner không thuộc local scope của hàm đó, mà nó nằm ở một nơi khác. Nhãn x này và nhãn x thuộc hàm outer liên kết lại với nhau, được gọi là closure.

    Returning the inner function

    Vậy điều gì sẽ xảy ra nếu như chúng ta không gọi hàm inner() bên trong hàm outer() mà thay vào đó, ta return nó. Khi gọi hàm outer(), hàm inner sẽ được tạo, và outer trả về hàm inner. Khi đó, closure nói trên vẫn đang còn tồn tại, chúng không bị mất đi. Vì vậy, khi gọi hàm outer(), trả về hàm inner, thực sự là chúng ta đang trả về một closure.
    Chúng ta có thể gán giá trị trả về từ hàm outer() cho một tên biến, ví dụ:

    fn = outer() # fn là closure
    fn() # python rocks!

    Khi chúng ta gọi hàm fn(), tại thời điểm đó – Python xác định giá trị của x trong một extended scope. Lưu ý rằng, hàm outer() đã chạy xong và đã kết thúc trước khi gọi hàm fn() –> scope của hàm outer đã được giải phóng. Vậy tại sao khi gọi hàm fn(), chúng ta vẫn nhận về được giá trị ‘python rocks!’ !!? –> closure.
    Thật magic! Để hiểu rõ hơn về closure, bạn hãy uống một chén trà rồi ngồi đọc tiếp nhé ;).

    Python Cells and Multi-Scoped Variables

    Xét ví dụ đơn giản sau:

    def outer():
        x = 'tuanh'
        def inner():
            print(x)
        return inner

    Giá trị của biến x được chia sẻ giữa 2 scope:

    • outer
    • closure

    Nhãn (label, name) x nằm trong 2 scope khác nhau nhưng luôn luôn refer tới cùng 1 giá trị. Python làm điều này bằng cách sử dụng một đối tượng trung gian, cell object.

    cell object đóng vai trò trung gian, và x sẽ tham chiếu gián tiếp đến đối tượng có giá trị ‘tuanh’. Trên thực tế, cả 2 biến x (trong outer và inner) đều trỏ đến cùng một cell object. Và khi chúng ta request giá trị của biến, Python thực hiện “double-hop” để lấy về giá trị cuối cùng.
    Bây giờ, chúng ta đã hiểu tại sao khi hàm outer() kết thúc, chúng ta vẫn có thể lấy được giá trị của biến x trong hàm inner rồi chứ.

    Closures

    Đến đây, chúng ta có thể nghĩ về closure như là một function + extended scope – scope mà chứa free variables.
    Giá trị của free variable là object mà cell trỏ tới. Mỗi khi function trong closure được gọi và free variable được tham chiếu:

    • Python tìm kiếm cell object, và sau đó bất kỳ cái gì cell đang trỏ tới.
    Nguồn: Fred Baptiste (Python Deep Dive – Functional)

    Introspection

    Chúng ta tiếp tục sử dụng ví dụ như trước:

    Nguồn: Fred Baptiste (Python Deep Dive – Functional)

    (more…)

  • Python Deep Dive: Hiểu closures, decorators và các ứng dụng của chúng – Phần 1

    Python Deep Dive: Hiểu closures, decorators và các ứng dụng của chúng – Phần 1

    Trong lập trình với Python thì Functional Programming đóng một vai trò vô cùng quan trọng và các functions trong Python là các first-class citizens. Điều đó có nghĩa là chúng ta có thể vận hành các functions giống như các objects khác:

    • Truyền các function giống như các đối số.
    • Gán một function cho một biến số.
    • Return một function từ một function khác.

    Dựa trên những điều này, Python hỗ trợ một kỹ thuật vô cùng mạnh mẽ: closures. Sau khi hiểu closures, chúng ta sẽ đi đến tiếp cận một khái niệm rất quan trọng khác – decorators. Đây là 2 khái niệm/kỹ thuật mà bất kỳ lập trình viên Python chuyên nghiệp nào cũng cần phải nắm vững.

    Table of contents

    Global and Local Scopes

    Scopes and Namespaces

    Khi một đối tượng được gán cho một biến (ví dụ: a = 100) thì biến đó trỏ đến một object nào đó và chúng ta nói rằng biến (name) đó được liên kết với đối tượng đó. Khi đó, object có thể được truy cập từ một số nơi trong code của chúng ta, sử dụng name (tên biến) nói trên.
    Tuy nhiên, hãy nhớ rằng tên biến và binding của nó (name và object) chỉ tồn tại trong một phần cụ thể của mã nguồn của chúng ta; phần mã nguồn mà ở đó name/binding được xác định – được gọi là lexical scope của các biến. Các bindings này được lưu trữ trong namespaces (mỗi scope có namespace riêng của nó).

    The Global Scope

    Global scope về cơ bản là module scope. Nó chỉ nằm trong một file .py duy nhất. Trong Python thì KHÔNG có khái niệm global scope (qua tất cả các mô đun trong toàn bộ ứng dụng) thực sự. Chỉ có một số ngoại lệ đó là có một số đối tượng built-in, được sử dụng toàn cục, chẳng hạn như: True, False, None, dict, print.

    Các biến built-in và global có thể được sử dụng bất kỳ đâu trong mô đun của chúng ta, kể cả trong các hàm.

    Global scopes được nested bên trong built-in scope.

    Nếu bạn tham chiếu một tên biến bên trong một scope và Python không tìm thấy nó trong không gian tên của scope đó –> Python sẽ tìm nó bên trong không gian tên của enclosing scope. Ví dụ trong Module1 bạn sử dụng đến nhãn True, Python sẽ tìm True bên trong không gian tên của built-in scope.

    The Local Scope

    Khi chúng ta tạo một function, chúng ta có thể tạo các tên biến bên trong function đó (sử dụng các đối số là ví dụ). Các biến được tạo bên trong function sẽ không được tạo cho đến khi function được gọi.

    Lưu ý: Giả sử chúng ta có function func1 bên trong module. Thì khi load module, Python sẽ compile mọi thứ và func1 sẽ nằm trong namespace của module đang được load. Tuy nhiên mọi thứ bên trong function sẽ chưa được tạo cho tới tận khi chúng được gọi bởi lời gọi hàm.

    def func1(a, b):
        # do something
        pass

    Mỗi khi function được gọi thì một scope mới sẽ được tạo. Và các biến được xác định bên trong function sẽ được gán cho scope đó – được gọi là function local scope. Lưu ý rằng đối tượng thực sự được tạo ra mà một biến trong hàm tham chiếu đến có thể khác nhau trong các lần hàm được gọi (đây là lý do tại sao đệ quy hoạt động!).

    Nested Scopes

    Các scope thường được nested trong các scope khác.
    Khi yêu cầu truy cập vào một object mà một biến tham chiếu đến. Ví dụ:

    print(a)

    Python sẽ tìm object được bound tới biến a như sau:

    • Đầu tiên, tìm trong local scope hiện tại. Nếu không thấy,
    • Lần lượt tìm tiếp lên các scope ‘bao bọc’ scope đang tìm.

    Thêm một ví dụ:

    # module1.py
    a = 10 # a thuộc global scope
    def myFunc(b):
        print(True) # print và True thuộc built-in scope
        print(a) # a thuộc global scope
        print(b) # b thuộc local scope
    
    myFunc(300) # một local scope mới được tạo, b trỏ đến đối tượng lưu trữ 300
    myFunc('a') # thêm một local scope nữa được tạo, b trỏ đến đối tượng lưu trữ 'a'

    The global keyword

    Khi chúng ta truy xuất một giá trị của một biến global bên trong một function. Python sẽ tìm kiến nó theo chuỗi các không gian tên tăng giần: local -> global -> built-in

    Điều gì sẽ xảy ra nếu như chúng ta sửa giá trị của một biến global bên trong một function ?

    a = 0
    def myFunc():
        a = 100 # Python sẽ hiểu rằng đây là biến local tại compile-time
        print(a)
    myFunc() ## 100
    print(a) ## 0

    Chúng ta có thể bảo Python rằng 1 biến được scoped bên trong global scope khi sử dụng nó bên trong một local scope (hàm) bằng cách sử dụng từ khóa global.

    a = 0
    def myFunc():
        global a
        a = 100
        print(a)
    myFunc() ## 100
    print(a) ## 100

    Global and Local Scoping

    Khi Python gặp một định nghĩa hàm tại compile-time, nó sẽ scan bất kỳ nhãn (biến) nào có giá trị assigned cho chúng (anywhere trong function). Nếu nhãn đó không được chỉ định là global thì nó sẽ là local. Các biến được tham chiếu nhưng not assigned một giá trị ở bất kỳ đâu trong function sẽ not be local, và Python sẽ, tại run-time, tìm kiếm chúng trong enclosing scopes.
    Ví dụ 1:

    # vidu1.py
    a = 10
    def func1():
        print(a) ## a chỉ được tham chiếu đến trong function chứ không được gán -> tại compile-time, a là non-local
    def func2():
        a = 100 ## a được gán trong function -> tại compile-time, a là local
    
    def func3():
        global a
        a = 100 ## a được gán và được chỉ định global -> tại compile-time, a là global

    Ví dụ 2:

    # vidu2.py
    def func4():
        print(a)
        a = 100 # tại compile-time, a là local
    
    # khi gọi hàm func4()
    # print(a) sẽ dẫn đến một run-time error bởi vì a là local,
    # và chúng ta đang tham chiếu đến nó trước khi chúng ta gán một
    # giá trị cho nó.
    func4()

    Code

    Tham khảo thêm các ví dụ về Global, local scopes tại:
    Click here

    Nonlocal Scopes

    Inner Functions

    Chúng ta có thể định nghĩa các hàm bên trong hàm khác, như sau:

    def outer_func():
        # some code here
        def inner_func():
            # some code here
    
        inner_func()
    outer_func()

    Cả hai hàm đều có quyền truy cập vào global scope, built-in scope và local scope tương ứng của chúng. Nhưng inner function cũng có thể truy cập vào enclosing scope của nó – ở đây là outer function.
    Scope mà không là local, cũng không là global, thì được gọi là nonlocal scope

    Modifying nonlocal variables

    Xét ví dụ sau:

    # vd.py
    def outer_func():
        x = 'TuAnh'
        def inner_func():
            x = 'HuuLinh'
        inner_func()
        print(x)
    outer_func() ## TuAnh

    Khi inner_func được compiled, python nhìn vào phép gán tới biến x -> nó xác định được biến x là local của hàm inner_func. Đứng ở góc độ inner_func thì biến x trong outer_func là nonlocal. Hai biến này trỏ đến 2 đối tượng khác nhau, vì vậy khi gọi outer_func() sẽ in ra màn hình chuỗi ‘TuAnh’ thay vì ‘HuuLinh’.

    Vậy làm cách nào để có thể sửa được biến x của hàm outer_func bên trong hàm inner_func. Rất đơn giản, giống như các biến global, chúng ta cần khai báo rõ ràng nonlocal với biến x bên trong hàm inner_func, như sau:

    def outer_func():
        x = 'TuAnh'
        def inner_func():
            nonlocal x
            x = 'HuuLinh'
        inner_func()
        print(x)
    outer_func() ## HuuLinh

    Nonlocal Variables

    Bất cứ khi nào Python nói rằng một biến là nonlocal, nó sẽ tìm kiếm trong enclosing local scopes lần lượt từ trong ra ngoài, cho tới khi bắt gặp lần đầu tên biến được chỉ định.
    Lưu ý: Python chỉ tìm trong local scopes, nó sẽ KHÔNG tìm trong global scope.

    Xem xét các ví dụ sau đây:

    # vd1.py
    def outer():
        x = 'tuanh'
        def inner1():
            x = 'python'
            def inner2():
                nonlocal x
                x = 'huulinh'
            print('inner(before): ', x)  # python
            inner2()
            print('inner(after): ', x)   # huulinh
        inner1()
        print('outer: ', x)              # tuanh
    outer()
    # vd2.py
    def outer():
        x = 'tuanh'
        def inner1():
            nonlocal x
            x = 'python'
            def inner2():
                nonlocal x
                x = 'huulinh'
            print('inner(before): ', x)  # python
            inner2()
            print('inner(after): ', x)   # huulinh
        inner1()
        print('outer: ', x)              # huulinh
    outer()
    # vd3.py
    x = 1000
    def outer():
        x = 'tuanh'
        def inner1():
            nonlocal x
            x = 'python'
            def inner2():
                global x
                x = 'huulinh'
            print('inner(before): ', x)  # python
            inner2()
            print('inner(after): ', x)   # python
        inner1()
        print('outer: ', x)              # python
    outer()
    print(x)                             # huulinh

    Authors

    [email protected]