Dãy hộp thư kim loại cũ đánh số, nhiều ô giống hệt nhau
Web development

Shopware sắp cache luôn trang của customer đã login. Plugin của bạn có để leak thông tin khách không?

Dãy hộp thư kim loại cũ đánh số, nhiều ô giống hệt nhau
Cùng một cái hộp cho tất cả mọi người. Photo by Elizabeth Kay on Unsplash

Tháng trước mình bật flag CACHE_REWORK trên staging của client để đo thử. Trang product detail cho khách đã đăng nhập giảm từ khoảng 450ms xuống 60ms, mình chụp Grafana gửi vào Slack khoe.

Hai tiếng sau QA nhắn lại: họ login bằng tài khoản test số 2, nhưng trang sản phẩm lại chào “Hans”, là tên của tài khoản test số 1.

Trước giờ khách đã login không bao giờ bị cache

Từ 6.4 tới giờ, HTTP cache của Shopware có một luật rất đơn giản: khách đã login, hoặc giỏ hàng có đồ, thì khỏi cache. Cookie sw-states mang giá trị logged-in hay cart-filled, CacheStateValidator nhìn thấy là cho request đi thẳng xuống PHP.

Về performance thì luật này khá tệ: shop B2B mà 90% traffic là khách đã login thì HTTP cache gần như không có tác dụng.

Nhưng nhờ nó mà plugin viết ẩu cũng không gây hậu quả gì. Bạn render tên khách, đơn gần nhất hay giá riêng theo hợp đồng thẳng vào HTML trang product cũng không sao, vì trang đó chưa bao giờ được cache.

Mình có một plugin đúng kiểu đó, viết từ thời 6.5 và chạy ba năm nay không ai phàn nàn gì.

// Code cũ: lỗi khi bật cache rework
class ProductPageSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [ProductPageLoadedEvent::class => 'onProductPageLoaded'];
    }

    public function onProductPageLoaded(ProductPageLoadedEvent $event): void
    {
        $customer = $event->getSalesChannelContext()->getCustomer();
        if ($customer === null) {
            return;
        }

        $event->getPage()->addExtension('acmeGreeting', new ArrayStruct([
            'firstName' => $customer->getFirstName(),
            'lastOrderNumber' => $this->lastOrderNumber($customer->getId()),
        ]));
    }
}

Route frontend.detail.page của Shopware khai báo defaults: ['_httpCache' => true]. Mình chưa bao giờ để ý chuyện đó, vì trước giờ với khách login thì nó không có tác dụng gì.

Cache hash mới gồm những gì

Cache rework có từ 6.7.6.0 (tháng 1 năm nay), nằm sau flag CACHE_REWORK, và theo Shopware sẽ thành mặc định ở 6.8. Thay vì bỏ qua cache cho khách login, giờ Shopware cache luôn nhưng tách bản theo context. Context được gom vào một cookie tên sw-cache-hash, và reverse proxy (hoặc Symfony HttpCache) dùng nó làm một phần của cache key.

Mình mở CacheHeadersService::buildCacheHash() ra đọc. Hash gồm: rule id (chỉ những rule có area liên quan, lấy qua getRuleIdsByAreas()), version id, currency id, tax state, và một cờ logged-in / not-logged-in. Với Store API thì thêm language id.

Trong đó không có customer id, cũng không có customer group id.

Nên nếu Hans và tài khoản test số 2 cùng EUR, cùng gross price, cùng khớp ba rule về giá, thì với cache họ là một người. Ai vào trước thì HTML của người đó được lưu, người vào sau nhận lại đúng bản đó.

Mình nghĩ Shopware chọn vậy là hợp lý. Nếu nhét customer id vào hash thì mỗi khách một bản cache, hit rate cho khách login gần như bằng không, chẳng khác gì quay lại thời sw-states. Họ chỉ hash theo những thứ ảnh hưởng tới giá, phần cá nhân hoá thì để plugin tự xử lý.

Có điều không ai nhắc bạn chuyện đó. Release note chỉ có đúng một dòng bảo extension phải “work with enabled cache for logged in customers”, và mình đọc xong cũng quên luôn.

Các cách sửa

Cách đầu tiên là thêm customer id vào hash. Shopware có sẵn event cho việc này, docs cũng có ví dụ:

use Shopware\Core\Framework\Adapter\Cache\Event\HttpCacheCookieEvent;

class CacheHashSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [HttpCacheCookieEvent::class => 'onCacheCookie'];
    }

    public function onCacheCookie(HttpCacheCookieEvent $event): void
    {
        $customerId = $event->context->getCustomerId();
        if ($customerId !== null) {
            $event->add('acme-customer', $customerId);
        }
    }
}

Nếu copy từ docs thì để ý: ví dụ trong đó đặt key là 'customer-group' nhưng giá trị lại là getCustomerId(). Hai cái này cho ra số bản cache chênh nhau rất nhiều, và mình nghi là có khá nhiều plugin đang copy y nguyên đoạn đó.

Cách này sửa mất chừng mười phút và chạy đúng, nhưng hit rate sẽ rất thấp. Shop có 20 nghìn khách B2B thì Varnish phải giữ 20 nghìn bản cho mỗi trang product, coi như mất gần hết lợi ích của cache rework với khách login.

Cách thứ hai: nếu thứ bạn render chỉ phụ thuộc customer group (bảng giá theo hợp đồng, badge “khách sỉ”), thì thêm customer group id thay vì customer id, số bản cache chỉ còn vài chục. Plugin của mình thì không dùng được cách này, vì tên khách và số đơn gần nhất là riêng từng người.

Cách thứ ba, cũng là cách mình chọn: không render dữ liệu cá nhân vào HTML được cache nữa. Trang product chỉ trả về một <div data-acme-greeting> rỗng, sau đó JS gọi một route riêng không có _httpCache để lấy tên và số đơn rồi điền vào. Cart widget trên header của Shopware cũng làm theo cách này từ lâu rồi.

Đổi lại thì mỗi lần load trang có thêm một request, và góc màn hình hơi nháy lúc tên khách hiện ra. Mình thấy vậy là chấp nhận được, thêm 30ms vẫn hơn là phải báo cáo lộ dữ liệu khách cho Datenschutzbeauftragter của client.

Ngoài ra có thể set $event->isCacheable = false trong cùng event đó để tắt cache cho request. Cách này không sai, nhưng như vậy thì bật flag cũng chẳng để làm gì.

Invalidation bị trễ 5 phút

Sửa xong vụ Hans thì mình gặp thêm một bug nữa, và bug này có trên 6.7 kể cả khi không bật flag.

Invalidation cache bây giờ mặc định là delayed. Bạn gọi CacheInvalidator::invalidate([...]), tag không bị xoá ngay mà được gom lại, rồi scheduled task shopware.invalidate_cache chạy mỗi 5 phút xử lý một lượt. Sản phẩm hết hàng ở ERP, sync về, trang product vẫn hiện “còn hàng” thêm vài phút nữa. Shop bán vé hay hàng số lượng ít thì mấy phút đó có thể thành vài đơn phải huỷ.

Muốn xoá ngay thì truyền thêm tham số thứ hai:

$this->cacheInvalidator->invalidate(['product-' . $productId], true);

Gọi từ ERP qua Admin API thì gửi kèm header sw-force-cache-invalidate: 1. Còn lúc debug mà thấy đổi gì cũng không ăn, chạy bin/console cache:clear:delayed trước khi nghi ngờ code của mình.

Mình chỉ force với stock và price, mấy thứ khác để trễ 5 phút cũng được, vì chính khoảng delay đó giúp hit rate cao hơn.

Có nên bật bây giờ không?

6.8 đã lùi sang 2027 nên vẫn còn thời gian, nhưng có một việc nên làm ngay.

Không cần bật flag, chỉ cần grep toàn bộ plugin của bạn tìm getCustomer() trong các subscriber của *PageLoadedEvent, rồi xem route tương ứng có _httpCache không. Chỗ nào có cả hai thì sẽ lỗi khi cache rework thành mặc định, chỉ là tới giờ chưa lộ ra vì trang đó chưa từng được cache.

Còn bật flag thật thì mình chỉ bật khi đã kiểm hết plugin bên thứ ba. Plugin mua trên store thì bạn không sửa được code, và mình chưa thấy plugin nào ghi “đã test với CACHE_REWORK” trong changelog. Cách test: dùng hai tài khoản cùng customer group, cùng currency, vào cùng một trang product. Nếu tài khoản thứ hai thấy bất kỳ thông tin nào của tài khoản thứ nhất thì plugin đó có vấn đề.

Nếu bạn đã bật trên production và gặp lỗi khác ngoài hai lỗi trên thì comment cho mình biết với.

Ảnh: Elizabeth Kay trên Unsplash (Unsplash License) — https://unsplash.com/photos/a-bunch-of-mail-boxes-with-numbers-on-them-9szCcOw4BWo

Dang Nguyen Hai (Mark) is a senior fullstack engineer in Ho Chi Minh City with 13+ years building e-commerce backends, payment systems and the AI features that sit on top of them. He works with Shopware and Laravel, and writes here about PHP internals, e-commerce architecture and getting AI features to behave in production.

Reply