Thiết kế service có tải ghi cao không gây tranh chấp tài nguyên

Để đảm bảo một hệ thống chạy tốt và ổn định, các việc thiết kế hệ thống như : chọn mô hình (micromonolithic), loại database, cách truyền tải dữ liệu (message queuehttpsocket,..), cách load balancing,… là việc rất quan trọng đánh dấu sự thành công của hệ thống. Song song với đó việc chúng ta thiết kế một service cũng là một mấu chốt quan trọng. Service bạn chịu trách nhiệm thiết kế có thể có các thao tác đọc ghi trên một dữ liệu tranh chấp. Khi 2 luồng của service của bạn cùng sửa một tài nguyên bị tranh chấp sẽ gây ra sự sai sót của hệ thống. Vậy khi thiết kế chúng ta cần có các kỹ thuật tránh điều này. Trong bài viết này tôi sẽ chia sẻ kinh nghiệm của mình khi thiết kế các service như vậy. Đây là phần một của bài viết sẽ nói về cách các bạn xử lý dữ liệu khi đến service chưa nói đến phân tải khi bạn có nhiều service cùng thực hiện một việc cũng như các service cùng một việc sẽ đồng bộ với nhau như thế nào.

Giả sử chúng ta cần thiết kế một service cho phép khách hàng thực hiện rút tiền. Chúng ta có một bảng đơn giản lưu lại thông tin của khách hàng như sau.

CREATE TABLE `users` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `age` int DEFAULT NULL,
  `total_money` int DEFAULT NULL,
  `version` int DEFAULT '1',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6028 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

Tôi sẽ insert trước vài dữ liệu vào bảng này.

mysql> select * from users;
+----+--------+------+-------------+---------+
| id | name   | age  | total_money | version |
+----+--------+------+-------------+---------+
|  1 | demtv1 |    1 |           0 |       1 |
|  2 | demtv2 |    2 |          10 |       1 |
|  3 | demtv3 |    3 |          90 |       1 |
|  4 | demtv4 |    4 |          50 |       1 |
|  5 | demtv5 |    5 |          70 |       1 |
+----+--------+------+-------------+---------+

Yêu cầu của service là khi khách hàng rút tiền số dư sẽ được thay đổi và được lưu lại trong database. Trong đề bài này phần tài nguyên tranh chấp là số tiền của khách hàng. Chúng ta sẽ thiết kế theo mô hình đơn giản sau

simpleService.png

  Lưu ý cho Lock trong Java

  Hướng dẫn Java Design Pattern – State

Cách đơn giản để tránh tranh chấp các loại tài nguyên này là bạn chỉ cho phép một luồng được thực hiện với thao tác với tài nguyên tranh chấp. Trong java chúng ta có thể dùng synchronized hoặc lock.

      public synchronized User doUpdate(int amount){
        //...
        user.setTotalMoney(user.getTotalMoney() - withDrawRequest.getAmount());
        user = service.updateUser(user);
        CacheManager.user.put(user.getId(), user);
        response.setMsg("ok");

        //...
    }

    private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.WriteLock lock = readWriteLock.writeLock();
    public  User doUpdate(int amount){
        lock.lock();
        try{
            //...
            user.setTotalMoney(user.getTotalMoney() - withDrawRequest.getAmount());
            user = service.updateUser(user);
            CacheManager.user.put(user.getId(), user);
            response.setMsg("ok");

            //...
        }finally {
            lock.unlock();
        }
    }
  • Dễ implement không cần database để xử lý tranh chấp
  • Không thể phục vụ nhiều khách hàng cùng lúc được.
  • Khó triển khai trên nhiều node service với các cách phân tải hay dùng hiện nay ví dụ : roundrobin, ip, WeightedResponseTimeRule … Phải có bộ phân tải hợp lý sao cho mỗi một node service chỉ phục vụ một số lượng user nhất định.

Tham khảo việc làm Java hấp dẫn trên TopDev

Với thiết kế này trên database sẽ có 1 trường mang đánh dấu là version của dữ liệu khi 2 luồng cùng thực hiện update trên cùng một dữ liệu trên database thì chỉ có 1 luồng thực hiện update thành công. Kỹ thuật đó là optimistic locking chi tiết mọi người xem tại link. Với spring boot các bạn có thể tham khảo tại link. Hoặc tham khảo đoạn code dưới đây

import lombok.Data;

import javax.persistence.*;

@Entity
@Table(name = "users")
@Data
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String name;

    private int age;

    private int totalMoney;

    @Version
    private int version;

}

// thao tác với database
   try {
      user = service.updateUser(user);
      CacheManager.user.put(user.getId(), user);
      response.setMsg("ok");
   } catch (IllegalStateException exception) {
      exception.printStackTrace();
      response.setMsg("version is not ok");
   }
  • Phương pháp này có thể phục vụ nhiều khách hàng khác nhau cùng thực hiện thao tác rút tiền.
  • Nếu bạn có nhiều node service phương pháp này vẫn phục vụ tốt cho bạn không sợ sai lệch dữ liệu
  • Rất dễ dàng để implement với tất cả developer
  • Phương pháp phải dùng database để bảo vệ tài nguyên tranh chấp gây lên cao tải cho database. Khi database cao tải bạn tăng số lượng service cũng không làm hệ thống bạn chạy tốt hơn.

Ý tưởng của request rất đơn giản thay vì mỗi request đến chúng ta có một luồng không xác định xử lý và thao tác với database. Giờ chúng ta sẽ tạo trước một số lượng luồng xử lý với database nhất định. Sau đó mỗi request đến ta sẽ lần lượt chia vào từng luồng này để xử lý. Điều này dẫn đến chúng ta sẽ ít sảy ra trường hợp insert fail vào database hơn vì với cùng request của một user chúng ta đã chỉ có 1 luồng duy nhất thao tác với nó nên trong cùng một node service sẽ không gây ra tranh chấp tài nguyên.

sharding_request.png

  • Không cần dùng đến database để xử lý tài nguyên tranh chấp cho nên không gây cao tải nên database khiến hệ thống chạy nhanh hơn. Khi bị hệ thống cao tải có thể add thêm service để phục vụ.
  • Nếu database bị cao tải ta có thể sử dụng phương pháp acsync insert/update vào database không gây ảnh hưởng đến trải nghiệm khách hàng.
  • Khó implement với những developer mới code. Mọi người có thể tham khảo link github sau được implement bằng springboot. Nếu nó có ích cho tôi xin 1 sao nhé. :))
  • Khi có nhiều node service thì cách này sẽ không thực sự hiệu quả với các cách load balacing thông thường như : roundrobin, ip, WeightedResponseTimeRule,… Vì như thế chúng ta sẽ không biết được cùng một user có rơi vào 2 node khác nhau không. Chúng ta nên sử dụng thêm version trong database.
  • Khi triển khai nhiều node service chúng ta cần có phương pháp phân tải thích hợp cho mỗi user sẽ chỉ vào một node nhất định. Khi node đó bị chết thì request của user đó sẽ chỉ chuyển sang một node khác. Có cơ hội sẽ trình bài ở các phần sau.

Ở đây tôi đã trình bày xong các cách giúp mọi người có thể thực hiện để tránh tranh chấp tài nguyên khi lập trình vào các request phải thay đổi dữ liệu hy vọng sẽ giúp ích được cho mọi người.

Tất cả các phương pháp đều có thể kết hợp với cache để tăng tốc độ khi không cần thực hiện giao tiếp nhiều với database. Ở các phần sau tôi sẽ giới thiệu một cách phân tải khá thông minh để áp dụng với cách thứ 3 trong bài này và nếu mọi người thấy việc implement cách thứ 3 khá khó thì tôi cũng sẽ có bài hướng dẫn mọi người implement tùy xem Hà Nội cách ly bao ngày nữa :))

Bài viết gốc được đăng tải tại demtv.hashnode.dev

Xem thêm:

Tuyển dụng IT lương cao, đãi ngộ hấp dẫn. Ứng tuyển ngay!