26 tháng 3, 2018

Xử lý tập dữ liệu lớn với Hibernate

Nếu bạn cần phải xử lý tập kết quả cơ sở dữ liệu lớn bằng Java, thì bạn có thể lựa chọn JDBC cho phép bạn khả năng kiểm soát ứng dụng ở tầng thấp. Mặt khác, nếu bạn đã lựa chọn sử dụng ORM trong ứng dụng của mình, nhưng sau đó thay đổi để sử dụng JDBC thì điều này lại khiến bạn mất khá nhiều tính năng thú vị như optimistic locking, caching,... Rất may là hầu hết các ORM framework, ví dụ như Hibernate, có vài lựa chọn có thể giúp bạn có thể xử lý tập kết quả cơ sở dữ liệu lớn.

Một ví dụ đơn giản như sau, giả định chúng ta có một bảng (được ánh xạ tới class DemoEntity) với một 100,000 bản ghi. Mỗi bản ghi bao gồm một cột đơn (được ánh xạ tới thuộc tính property trong lớp DemoEntity) giữ một số kí tự ngẫu nhiên với dung lượng ~ 2KB. JVM được chạy với -Xmx250m - giả sử chúng ta chỉ có tối đa 250MB cho bộ nhớ JVM trên hệ thống. Công việc của bạn là đọc toàn bộ bản ghi có trong bảng, thực hiện một vài xử lý và sau đó lưu lại kết quả. Ta giả định rằng kết quả từ việc xử lý tập dữ liệu lớn không bị thay đổi. Để bắt đầu ta sẽ thử xử lý đơn giản trước, thực hiện truy vấn lấy toàn bộ dữ liệu:

new TransactionTemplate(txManager).execute(new TransactionCallback<Void>() {
    @Override
    public Void doInTransaction(TransactionStatus status) {
        Session session = sessionFactory.getCurrentSession();
        List<DemoEntity> demoEntitities = (List<DemoEntity>) session.createQuery("from DemoEntity").list();
        for(DemoEntity demoEntity : demoEntitities) {
           // Process and write result
        }
        return null;
    }
});

Sau vài giây, ta nhận được message sau:

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

Hình 1:

Hình 1

Rõ ràng điều này không giải quyết được vấn đề của chúng ta. Để khắc phục vấn đề này, chúng ta sẽ chuyển qua sử dụng scrollable trong Hibernate để thực thi câu truy vấn trên, ánh xạ toàn bộ kết quả vào các đối tượng entityreturn chúng. Khi sử dụng scrollable result set, các bản ghi được biến đổi sang các entity trong cùng một lần:

new TransactionTemplate(txManager).execute(new TransactionCallback<Void>() {

    @Override
    public Void doInTransaction(TransactionStatus status) {
        Session session = sessionFactory.getCurrentSession();
        ScrollableResults scrollableResults = session.createQuery("from DemoEntity").scroll(ScrollMode.FORWARD_ONLY);

        int count = 0;
        while (scrollableResults.next()) {
            if (++count > 0 && count % 100 == 0) {
                System.out.println("Fetched " + count + " entities");
            }
            DemoEntity demoEntity = (DemoEntity) scrollableResults.get()[0];
            // Process and write result
        }
        return null;
    }
});

Sau khi chạy đoạn code trên:

...
Fetched 49800 entities
Fetched 49900 entities
Fetched 50000 entities
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

Hình 2:

Hình 2

Mặc dù ta đang sử dụng một scrollable result set, nhưng mỗi đối tượng trả về là một attached object và trở thành một phần của persistence context (aka session). Kết quả này thực sự rất giống với ví dụ đầu tiên khi mà chúng ta sử dụng session.createQuery("from DemoEntity").list(). Tuy nhiên, với phương cách đầu tiên ta không phải điều khiển bằng tay, chúng ta sẽ có được kết quả khi Hibernate hoàn thành công việc của nó. Mặt khác, bằng cách sử dụng một scrollable result set sẽ cho phép chúng ta khả năng kiểm soát quá trình xử lý và giải phóng bộ nhớ khi cần. Như ta có thể thấy thì nó sẽ không tự động giải phóng bộ nhớ, bạn phải cho Hibernate biết khi nào sẽ thự hiện việc này. Bạn có thể tham khảo vài cách thực hiện như sau:

  • Thu hồi vùng nhớ của đối tượng trong persistent context sau khi xử lý xong
  • Xóa toàn bộ session

Chúng ta sẽ lựa chọn cách đầu tiên, ở ví dụ trên tại dòng số 13 (// Process and write result) ta sẽ thêm đoạn code sau:

session.evict(demoEntity);

Lưu ý:

  • Nếu bạn đã thực hiện bất kì thay đổi nào trên một hoặc nhiều đối tượng entity hãy chắc chắn rằng bạn đã thực hiện lệnh flush session trước khi thực hiện evict hoặc clear, nếu không thì câu truy vấn sẽ bị giữ lại vì nếu thực hiện sau thì Hibernate sẽ không gửi các thay đổi này đến cơ sở dữ liệu
  • Evict hoặc clear sẽ không xóa các đối tượng entity khỏi second level cache. Nếu bạn cho phép và đang sử dụng second level cache, bạn có thể phương thức sessionFactory.getCache().evictXxx() nếu muốn loại bỏ đối tượng khỏi second level cache
  • Sau thời điểm bạn sử dụng evict trên một entity, thì nó sẽ không liên kết với session nữa. Bất kì thay đổi nào được thực hiện trên đối tượng entity sẽ không được tự động phản ánh vào trong database. Nếu bạn đang sử dụng lazy loading, khi truy cập vào bất kì thuộc tính nào mà không được load trước khi thực hiện evict thì một ngoại lệ org.hibernate.LazyInitializationException sẽ được ném ra. Vì vậy, về cơ bản hãy đảm bảo xử lý xong các đối tượng entity (hoặc ít nhất khởi tạo những thứ cần thiết) trước khi bạn sử dụng evict hoặc clear

Sau khi chạy lại chương trình, ta sẽ thấy nó được thực thi thành công:

...
Fetched 99800 entities
Fetched 99900 entities
Fetched 100000 entities

Hình 3:

Hình 3

Bạn cũng có thể thiết lập câu truy vấn là read-only, điều này cho phép Hibernate thực hiện thêm một vài tối ưu.

ScrollableResults scrollableResults = session.createQuery("from DemoEntity").setReadOnly(true).scroll(ScrollMode.FORWARD_ONLY);

Thực hiện điều này chỉ mang lại sự khác biệt rất nhỏ trong việc sử dụng bộ nhớ.

Chúng ta đã có thể xử lý được 100,000 bản ghi, nhưng Hibernate có một tùy chọn khác để xử lý số lượng lớn: Stateless session. Bạn có thể lấy được scrollable result set từ một stateless session tương tự với cách làm với một session thông thường. Một stateless session nằm ngay bên trên của JDBC. Gần như, Hibernate sẽ chạy ở chế độ tắt toàn bộ các tính năng, nghĩa là sẽ không có persistent context, không có 2nd level caching, không có dirty detection, không có lazy loading, về cơ bản là sẽ không có gì. Javadoc có mô tả như sau:

/**
 * A command-oriented API for performing bulk operations against a database.
 * A stateless session does not implement a first-level cache nor interact with any 
 * second-level cache, nor does it implement transactional write-behind or automatic 
 * dirty checking, nor do operations cascade to associated instances. Collections are 
 * ignored by a stateless session. Operations performed via a stateless session bypass 
 * Hibernate's event model and interceptors.  Stateless sessions are vulnerable to data 
 * aliasing effects, due to the lack of a first-level cache. For certain kinds of 
 * transactions, a stateless session may perform slightly faster than a stateful session.
 *
 * @author Gavin King
 */

Điều duy nhất sẽ được thực hiện đó là chuyển đổi các bản ghi thành các đối tượng java. Đây có thể là một lựa chọn hấp dẫn bởi vì nó giúp bạn thoát khỏi ý nghĩ là khi nào sẽ cần phải evict/flush.

new TransactionTemplate(txManager).execute(new TransactionCallback<Void>() {

    @Override
    public Void doInTransaction(TransactionStatus status) {
        sessionFactory.getCurrentSession().doWork(new Work() {
            @Override
            public void execute(Connection connection) throws SQLException {
                StatelessSession statelessSession = sessionFactory.openStatelessSession(connection);
                try {
                    ScrollableResults scrollableResults = statelessSession.createQuery("from DemoEntity").scroll(ScrollMode.FORWARD_ONLY);

                    int count = 0;
                    while (scrollableResults.next()) {
                        if (++count > 0 && count % 100 == 0) {
                            System.out.println("Fetched " + count + " entities");
                        }
                        DemoEntity demoEntity = (DemoEntity) scrollableResults.get()[0];
                        // Process and write result 
                    }
                } finally {
                    statelessSession.close();
                }
            }
        });
        return null;
    }
});

Hình 4:

Hình 4

Bên cạnh thực tế là sử dụng stateless session sẽ rất tối ưu bộ nhớ, thì khi sử nó cũng có một vài hạn chế, bạn có thể nhận thấy là ta phải thực hiện mở và đóng stateless session bằng tay một cách tường minh: sẽ không có bất kì phương thức nào giống như sessionFactory.getCurrentStatelessSession() hoặc bất kì cách thức tích hợp với Spring để quản lý stateless session (vào thời điểm hiện tại). Mặc định khi mở một stateless session sẽ cấp phát mới một đối tượng java.sql.Connection (nếu bạn sử dụng phương thức openStatelessSession()), do đó nó sẽ gián tiếp tạo ra một transaction thứ hai. Bạn có thể tránh được việc này bằng cách sử dụng API của Hibernate như trong ví dụ trên, ta sẽ lấy được current connection và truyền đối số qua phương thức openStatelessSession(Connection connection). Ta thực hiện đóng session trong khối finally sẽ không có tác động đến kết nối vật lý vì nó được quản lý bởi Spring: chỉ có connection logic bị đóng và một connection logic mới được tạo ra khi mở một stateless session.

Như đã nói ở trước đó, Hibernate sẽ chạy trong chế độ tất cả tính năng bị vô hiệu và các đối tượng entity được trả về ở trạng thái tách rời. Nghĩa là, mỗi entity mà bạn chỉnh sửa thì bạn sẽ phải gọi phương thức statelessSession.update(entity) trực tiếp. Đầu tiên, ta sẽ thử chỉnh sửa một đối tượng entity:

new TransactionTemplate(txManager).execute(new TransactionCallback<Void>() {
    @Override
    public Void doInTransaction(TransactionStatus status) {
        sessionFactory.getCurrentSession().doWork(new Work() {
            @Override
            public void execute(Connection connection) throws SQLException {
                StatelessSession statelessSession = sessionFactory.openStatelessSession(connection);
                try {
                    DemoEntity demoEntity = (DemoEntity) statelessSession.createQuery("from DemoEntity where id = 1").uniqueResult();
                    demoEntity.setProperty("test");
                    statelessSession.update(demoEntity);
                } finally {
                    statelessSession.close();
                }
            }
        });
        return null;
    }
});

Ý tưởng là chúng ta sẽ thực hiện mở một stateless session với connection hiện tại, và như javadoc StatelessSession chỉ ra rằng sẽ không thực hiện ghi dữ liệu sau khi chỉnh sửa đổi dữ liệu, mỗi câu lệnh được thực hiện bởi stateless session sẽ được gửi trực tiếp đến database. Cuối cùng, khi transaction (được bắt đầu bởi TransactionTemplate) được commit thì kết quả sau khi được chỉnh sửa sẽ hiển thị trong database.