[{"content":"Marketplace của tôi là một ứng dụng React Native Web, export bằng Expo. Trước khi JavaScript chạy, trang listing nào cũng chỉ có đúng một thẻ script và một thẻ div rỗng. Với người dùng thì chẳng sao, nhưng với công cụ tìm kiếm thì coi như xong: Google thỉnh thoảng chạy được JS, Bing thì không, còn crawler của Naver — thứ quan trọng nhất với một trang web dành cho cộng đồng người Hàn ở New Zealand — thì chắc chắn là không.\nBài này kể lại quá trình tôi làm cho các trang đó hiện ra với công cụ tìm kiếm, rồi chuyện một cache rule phá hỏng tất cả theo cách tôi không hề lường trước.\nDynamic rendering, chứ không phải cloaking # Cách sửa thì chẳng có gì mới, và được chính Google chấp nhận: nhận diện crawler, rồi trả cho nó HTML đã render sẵn ở server, chứa đúng nội dung mà SPA lẽ ra sẽ vẽ lên màn hình. Không phải nội dung khác. Vẫn nội dung ấy, chỉ là có sớm hơn.\nVì vậy, /tabs/used-good-view?id=... được tách làm hai nhánh. Trình duyệt nhận Expo shell. Crawler nhận một tài liệu HTML thật sự: một thẻ \u0026lt;h1\u0026gt;, một thẻ \u0026lt;dl\u0026gt; liệt kê thông tin (giá, tình trạng, danh mục, địa điểm, còn hàng hay không, số ảnh, ngày đăng), mỗi ảnh một thẻ \u0026lt;img\u0026gt; kèm alt text tự sinh, một đoạn trích nội dung, và link sang tám listing cùng nhóm để trang không bị mồ côi.\nPhần cuối này hoá ra quan trọng hơn tôi nghĩ. Trước khi có mấy internal link đó, khoảng một nghìn trang listing nằm trơ trọi, không có link nào trỏ tới, và Search Console cho thấy lượng crawl chững lại ở mức bảy đến mười một request mỗi ngày. Crawler mà không tìm thấy trang của bạn thì trang có hay đến mấy cũng vô nghĩa.\nCái bẫy: tên công ty không phải là tên bot # Nhận diện crawler thực chất là so khớp user-agent, và tôi dính đòn đúng ở bước này.\nCác nền tảng Hàn Quốc cho crawler chạy dưới những cái tên chẳng liên quan gì tới tên công ty. Crawler của Naver tên là Yeti. Của Daum là Daumoa. Còn scraper lấy preview link của Kakao là kakaotalk-scrap. Không cái nào mang đúng tên công ty.\nTrong khi đó, cũng chính mấy công ty này làm ứng dụng có trình duyệt nhúng bên trong, và trình duyệt đó gửi user-agent y như trình duyệt thật, chỉ gắn thêm tên ứng dụng ở cuối. Trình duyệt trong ứng dụng KakaoTalk ghi KAKAOTALK/10.5.1. Scraper của Kakao thì ghi kakaotalk-scrap/1.0.\nCứ so khớp theo kakao là bạn tóm luôn cả hai. Tôi đã làm đúng như vậy, thế là người dùng KakaoTalk, ứng dụng Naver hay ứng dụng Daum nào mở link cũng nhận về trang dành cho crawler: không JavaScript, không điều hướng, không đăng nhập. Một tài liệu hoàn toàn chuẩn chỉnh, nhưng với con người thì vô dụng.\nCách sửa là một quy tắc về thứ tự kiểm tra, và đáng để viết hẳn ra thành quy tắc:\ndef is_crawler(request): \u0026#34;\u0026#34;\u0026#34;An in-app browser is never a crawler, even when its UA carries the same vendor name the vendor\u0026#39;s bot does — so that check comes first.\u0026#34;\u0026#34;\u0026#34; ua = request.META.get(\u0026#34;HTTP_USER_AGENT\u0026#34;, \u0026#34;\u0026#34;) if not ua or _INAPP_BROWSER_RE.search(ua): return False return bool(_CRAWLER_RE.search(ua)) Trình duyệt trong ứng dụng được kiểm tra trước, và luôn thắng. Còn token crawler nào cũng phải là tên của một bot, tuyệt đối không phải tên công ty:\n# Korean engines. Naver crawls as Yeti; Daum as Daumoa; Kakao\u0026#39;s link-preview # scraper as kakaotalk-scrap. None of the three is the bare vendor name. r\u0026#34;yeti/|naverbot|daumoa|kakaotalk-scrap|kakao[a-z]*bot|\u0026#34; Rồi tôi đặt Cloudflare đứng trước # Origin chỉ là một machine shared-cpu duy nhất đặt ở Sydney, chịu tải ổn định được khoảng hai mươi đến ba mươi request mỗi giây. Googlebot có chừng 995 URL cần đi qua. URL nào cũng chạy thẳng về Sydney, vì cf-cache-status ở tất cả đều là DYNAMIC: Cloudflare không cache HTML trừ khi có rule bảo nó làm vậy. Cả zone chỉ khoảng 16% được cache, và phần quan trọng thì không nằm trong số đó.\nVậy thì thêm một cache rule cho các trang crawler. Đơn giản. Có điều, một URL listing không chỉ có một body. Nó có tới ba.\nCrawler xin HTML thì nhận trang render ở server. Người dùng thật thì nhận Expo shell. Agent gửi Accept: text/markdown thì nhận markdown dựng từ cơ sở dữ liệu, vì đem một HTML shell rỗng đi chuyển đổi thì cũng chỉ ra một tài liệu rỗng. Origin có trả Vary: Accept, còn Vary theo user-agent thì đằng nào cũng vô vọng. Và đây là điều tôi không hề biết:\nCloudflare bỏ qua Vary với HTML. Cả ba body dùng chung một cache key.\nBody nào tới trước sẽ chiếm luôn URL đó cho mọi lượt truy cập sau ở edge location ấy, trong suốt một tiếng mà s-maxage của origin cho phép, cộng thêm một ngày stale-while-revalidate.\nTôi được tận mắt thấy chuyện này trên production, theo cả hai chiều:\nMột người dùng ứng dụng Naver mở một URL listing trước, cache giữ lại trang crawler, rồi sau đó Safari bình thường trên iPhone nhận đúng trang ấy dưới dạng HIT. Một con người ngồi nhìn một tài liệu không có tí JavaScript nào. Một người dùng thật mở một URL trước, cache giữ lại Expo shell, và Googlebot nhận về cái shell rỗng. Đúng cái lỗi mà cả nỗ lực SEO sinh ra để ngăn chặn, giờ lại còn được phục vụ nhanh hơn, ngay từ edge. Cái rule sửa lỗi bằng cách thu hẹp phạm vi # Bạn không thể bắt Cloudflare tôn trọng Vary với HTML. Cái bạn làm được là đảm bảo trong ba body đó, chỉ đúng một cái có cửa vào cache:\n(starts_with(http.request.uri.path, \u0026#34;/tabs/\u0026#34;) and not any(http.request.headers[\u0026#34;accept\u0026#34;][*] contains \u0026#34;text/markdown\u0026#34;) and (lower(http.user_agent) contains \u0026#34;googlebot\u0026#34; or ...) and not (lower(http.user_agent) contains \u0026#34;kakaotalk/\u0026#34; or ...)) Chỉ khớp user-agent của crawler, và loại trừ request xin markdown. Giờ request của người dùng thật không khớp rule nào cả, nên nó là DYNAMIC và đi về origin như trước. Chỉ request của crawler mới được đọc hoặc ghi cache, nên body duy nhất có thể nằm trong đó là body dành cho crawler.\nEdge TTL để là respect_origin, nên thời gian sống của cache vẫn do chính phần mã render trang quyết định, chứ không bị đóng cứng trong một cái dashboard nào đó.\nCó một chi tiết vận hành rất dễ bỏ sót: thu hẹp rule không xoá những gì đã nằm sẵn trong cache. Bạn phải purge, không thì vẫn tiếp tục trả ra những entry nhiễm độc mà mình vừa ngừng tạo ra.\nHai danh sách, và vì sao sai lệch giữa chúng không đối xứng # Rule này cần một danh sách user-agent của crawler, mà Django thì đã có sẵn một danh sách như vậy. Cùng một kiến thức mà nằm hai bản ở hai hệ thống thường là dấu hiệu có vấn đề. Nhưng ở đây, điều thú vị là chuyện gì xảy ra khi hai bản lệch nhau, vì hậu quả không đối xứng:\nDanh sách bot trong Cloudflare phải luôn là tập con của danh sách trong Django. Nếu có token mà Cloudflare coi là bot còn Django thì không, SPA shell sẽ bị cache dưới key chỉ dành cho crawler, rồi bị trả cho một crawler thật. Danh sách loại trừ trình duyệt trong ứng dụng phải luôn là tập cha của danh sách trong Django. Loại trừ thừa thì cùng lắm bạn mất một lần cache hit. Sai theo chiều an toàn, dù ở danh sách nào, thì bạn chỉ mất một ít cache. Nhưng sai danh sách đầu theo chiều ngược lại là bạn quay về cảnh trả cho Googlebot một trang trống. Nhân tiện, cũng vì sự bất đối xứng đó mà hai danh sách dùng contains chứ không dùng regex: matches đòi gói Business, còn contains là đủ cho một danh sách được phép xấp xỉ, miễn là lệch về phía đã biết là an toàn.\nMột bất biến làm hỏng build thay vì làm hỏng trang # Các trang crawler nhúng URL ảnh dạng presigned, có hiệu lực bảy ngày. Còn bản thân các trang thì được cache ở edge một tiếng, cộng thêm một ngày stale-while-revalidate.\nHai con số này dính dáng tới nhau. Nếu có ngày thời gian cache vượt quá thời hạn của ảnh, edge sẽ bắt đầu trả ra những trang mà ảnh nào cũng dính lỗi 403. Sẽ chẳng có cảnh báo nào kêu cả, vì HTML vẫn hoàn toàn bình thường.\nNên module giữ mấy hằng số đó sẽ từ chối import nếu quan hệ này bị phá vỡ:\nCRAWLER_IMAGE_TTL = 7 days # raises at import if s-maxage + stale-while-revalidate \u0026gt;= CRAWLER_IMAGE_TTL Một ràng buộc giữa hai hằng số nằm ở hai tệp khác nhau thì gần như vô hình khi code review, nhưng lộ ra ngay khi process khởi động. Vậy thì cứ đặt phép kiểm tra ở chỗ process khởi động.\nHai thứ Cloudflare làm mà tôi phải gỡ lại # Vary: Origin khiến bundle không vào được cache. django-cors-headers đóng dấu Vary: Origin lên mọi response mà nó đi qua, còn Cloudflare thì không cache bất cứ thứ gì vary theo nhiều hơn Accept-Encoding. Thế là bundle JavaScript 1.6 MB — thứ nặng nhất trên critical path — lặng lẽ bị loại hẳn khỏi edge. Cách sửa là một middleware đặt đầu tiên trong danh sách, để nó chạy sau cùng lúc response đi ra, và viết lại Vary cho các đường dẫn asset /_expo/.\nNén ở edge còn tệ hơn nén ở origin. Để Cloudflare tự nén Brotli on the fly thì ra 2,041,742 byte, trong khi gzip của chính origin chỉ ra 1,658,115. To hơn hai mươi ba phần trăm, chỉ để đổi lấy cái sướng là không phải nghĩ ngợi gì. Nén on the fly tối ưu cho CPU chứ không tối ưu cho tỉ lệ nén; asset nén sẵn từ origin vẫn thắng.\nBáo cho công cụ tìm kiếm mà không cần chờ bị crawl # Có hai giao thức, nhưng gộp cả hai lại thì phạm vi phủ vẫn ít đến đáng thất vọng.\nIndexNow chỉ cần một cú ping là phủ được Bing, Yandex và Seznam. Google và Naver thì không tham gia. Tôi cho nó chạy trên post_save trong một daemon thread với timeout năm giây; nó bỏ qua listing đã bán hoặc đã đóng, và không làm gì cả nếu chưa có key. Gặp lỗi mạng thì nó ghi một dòng warning vào log và không bao giờ làm hỏng lần lưu đã kích hoạt nó. Với một kênh phụ kiểu best-effort, đó là cách hành xử duy nhất chấp nhận được.\nIndexing API của Google chỉ nhận đúng hai loại schema, và một trong hai là JobPosting, thứ mà tôi lại có. Tin tuyển dụng còn mở thì gửi URL_UPDATED, tin đã đóng thì gửi URL_DELETED. Lệnh xoá đó chỉ trung thực vì listing đã đóng cũng trả noindex, và mã nguồn có ghi chú rõ chuyện này, để lỡ ai gỡ noindex đi thì cũng biết mình còn làm hỏng thêm thứ gì.\nCó hai chỗ làm tôi mất thời gian. Thứ nhất, quota là 200 URL mỗi ngày, mà một lần chạy import từ scraper bắn signal hàng trăm lần — nên listing import phải được bỏ qua, không thì một lần import đốt sạch ngân sách của cả ngày. Thứ hai, endpoint là urlNotifications:publish, dùng dấu hai chấm. Dùng dấu gạch chéo là Google trả về một trang HTML 404 trơn, trông y hệt như API đang bị tắt.\nStructured data dạy tôi những gì # Ba quy tắc, cái nào cũng học được sau khi bị Search Console phàn nàn:\nKhông parse được thì bỏ ra. Mức lương gửi đến dưới dạng text tự do. Trước đây tôi nhét nguyên chuỗi đó vào baseSalary.value, vốn không hợp lệ, thế là tin tuyển dụng nào cũng bị gắn cờ thiếu đơn vị. Giờ lương nào không parse được thì đơn giản là không có. Đừng bao giờ bịa ra một field chỉ để chiều schema. Tin tuyển dụng hiếm khi ghi địa chỉ cụ thể đến tên đường. Rất dễ bị cám dỗ bịa ra một địa chỉ nghe hợp lý cho PostalAddress. Nhưng một địa chỉ bịa trên tin tuyển dụng còn tệ hơn một địa chỉ thiếu, nên tôi chỉ đưa vào locality và quốc gia. Ngày hết hạn không phải là tuỳ chọn. Google bỏ qua JobPosting nào không có validThrough, nên tôi tự suy ra một giá trị, kèm mức sàn để một listing cũ không tự nhận là đã hết hạn từ hôm qua. Còn quy tắc thứ tư, về sitemap: đừng khai changefreq: daily cho mọi thứ. Bảo Googlebot crawl lại một nghìn listing mỗi ngày thì trong mắt nó, host của bạn đang quá tải, và nó sẽ đáp lại bằng cách giảm tốc độ crawl cho cả trang web. Hãy suy ra giá trị này từ lần gần nhất nội dung thực sự thay đổi.\nSitemap cũng có một mức sàn về chất lượng: tiêu đề và nội dung cộng lại phải đủ bốn mươi ký tự. Search Console từng báo khoảng một nghìn listing ở trạng thái “Crawled – currently not indexed”, và có một trang đồ cũ mà toàn bộ nội dung riêng của nó chỉ gồm một tiêu đề đúng một chữ và một số điện thoại. Bốn mươi ký tự là tôi cố tình đặt thấp, vì 471 trên 484 listing viết bằng tiếng Hàn, mà tiếng Hàn chứa được nhiều ý hơn trên mỗi ký tự; một ngưỡng canh theo tiếng Anh sẽ loại mất một nửa marketplace. Listing nào nội dung mỏng thì vẫn crawl được qua các trang index, và sẽ tự động quay lại sitemap khi có người sửa chúng.\nTôi rút ra được gì # Trả nhiều body khác nhau ở cùng một URL trước hết là bài toán cache key, rồi mới đến chuyện khác. Hãy tìm hiểu xem CDN của bạn lấy key theo những gì, và cứ giả định nó bỏ qua Vary cho đến khi bạn chứng minh được điều ngược lại với đúng loại content type đó. Khi hai hệ thống phải thống nhất về một danh sách, hãy xác định chiều sai nào là an toàn, rồi ghi điều đó ngay cạnh cả hai bản. Nói thẳng “hai bản này có thể lệch nhau, và đây là cách duy nhất chúng được phép lệch” vẫn hơn là giả vờ chúng không bao giờ lệch. So khớp bot theo tên bot, đừng bao giờ theo tên công ty. Nhất là ở ngoài thế giới nói tiếng Anh, nơi chính công ty làm bot cũng làm luôn cái trình duyệt mà người dùng của bạn đang cầm trên tay. Bất biến giữa những hằng số nằm xa nhau thì hãy đặt ở chỗ nào hỏng thật to. Kiểm tra lúc import chẳng tốn gì, và không ai lách qua được. ","date":"09/09/2026","description":"Chuyện làm cho một ứng dụng JavaScript hiện ra trên Naver và Google, và cái cache rule đã lặng lẽ trao nhầm trang của bên này cho bên kia.","externalUrl":null,"permalink":"/vi/posts/2026/2026-09-09-cloudflare-crawlers-cache/","section":"Bài viết","summary":"","title":"Cloudflare trả về cho Googlebot một trang trống","type":"posts"},{"content":"Tôi mở dashboard của Cloudflare để tìm hiểu một đợt traffic tăng đột biến, và phát hiện bên dưới nó là một chuyện tệ hơn nhiều: trong một ngày, 22% tổng số request mà ứng dụng của tôi phục vụ đã bị lỗi. Không có cảnh báo nào bắn ra. Health check nào cũng xanh. Log trông hoàn toàn bình thường, vì những request bị lỗi chưa bao giờ tới được ứng dụng.\nHóa ra thủ phạm lại là chính script khởi động container của tôi: nó làm những việc hoàn toàn hợp lý, chỉ có điều là vào đúng thời điểm tệ nhất.\nĐợt traffic tăng vọt chỉ là cú đánh lạc hướng # Ban đầu tôi chỉ định tìm hiểu một khung giờ nhận khoảng 4,900 request, trong khi trung vị chỉ là 121. Có bốn mươi hai visitor khác nhau, tức là 110 request cho mỗi visitor. Người thật thì không lướt web kiểu đó.\nĐó là một con scanner: một con bot duy nhất lần lượt dò qua các subdomain do nó tự bịa ra, và hỏi từng cái xem có secret nào không:\napi-stage-asia.marucommunity.com/.env.local api-us-east-1-demo.marucommunity.com/.env api-eu-uat.marucommunity.com/wp-config.php~ app-test-us-east-1.marucommunity.com/actuator/configprops prod-api-use2.marucommunity.com/.env.local relay.marucommunity.com/.env.production Khoảng ba mươi hostname, đủ mọi biến thể .env bạn nghĩ ra được, thêm cả các endpoint actuator của Spring Boot trên một ứng dụng Django. Tất cả đều nhận 301 chuyển về host chính, và không có gì bị lộ ra ngoài. Nhàm chán, và thật sự vô hại.\nNhưng nhân tiện đang mở trang analytics, tôi thử nhóm số liệu của cùng ngày đó theo status code. Vấn đề thật nằm ở đây.\nStatus Số request trong 24h Tổng 11,350 504 Gateway Timeout 2,474 Hai mươi hai phần trăm. Và số lỗi này không rải đều khắp trang, mà dồn vào đúng những chỗ đau nhất:\nEndpoint Số lỗi 504 số thông báo chưa đọc 621 danh sách thông báo 614 số tin nhắn chưa đọc 469 feed cursor 210 Đây đều là những endpoint mà ứng dụng di động của tôi poll mỗi 30 giây. Nên đây không phải một con số tỉ lệ lỗi trừu tượng. Đó là điện thoại của từng người dùng, âm thầm không tải được badge tin chưa đọc, suốt cả ngày, và nhiều khả năng là đã như vậy mấy tuần liền.\nVì sao log vẫn trông bình thường # Phản xạ đầu tiên của tôi là ứng dụng đã bị chậm đi. Nhưng không phải. Tôi lấy từ kho log ra các dòng log request của chính ứng dụng trong một khung giờ tệ nhất:\n[2026-09-08 01:00:29] \u0026#34;GET /api/v1/notifications/unread_count/\u0026#34; 304 61.615 [2026-09-08 01:00:29] \u0026#34;GET /api/v1/notifications/\u0026#34; 304 63.568 [2026-09-08 01:00:59] \u0026#34;GET /api/v1/notifications/unread_count/\u0026#34; 304 58.742 [2026-09-08 01:00:59] \u0026#34;GET /api/v1/notifications/\u0026#34; 304 105.438 Từ bốn mươi bốn đến một trăm mili giây, toàn bộ đều là 304. Origin chạy rất nhanh.\nĐó là manh mối quan trọng, và đáng để đúc kết thành một quy tắc: nếu edge báo lỗi mà origin chưa từng nghe thấy, thì request đã chết trước khi tới nơi. Lúc đó, hãy thôi đọc log ứng dụng và bắt đầu nhìn vào những gì đứng trước nó.\nTrong trường hợp của tôi, thứ đứng trước là proxy của Fly.io, và câu trả lời nằm ở cách các machine của tôi được lên lịch chạy.\nScale to zero là lời hứa về tiền, không phải về latency # Cấu hình của tôi trông như thế này, và thoạt nhìn thì khá hợp lý:\n[http_service] auto_stop_machines = \u0026#39;stop\u0026#39; auto_start_machines = true min_machines_running = 1 Machine nào rảnh thì tự dừng. Có traffic thì tự bật lại. Dùng bao nhiêu trả bấy nhiêu. Với một ứng dụng nhỏ có traffic lúc lên lúc xuống thất thường, đây đúng là thứ bạn cần, và đó cũng là lý do tôi khai báo mười machine nhưng gần như lúc nào cũng chỉ có một cái đang chạy.\nCái giá bị giấu đi ở đây là latency. Khi một request tới một machine đang dừng, sẽ có người phải ngồi chờ machine đó boot. Nếu thời gian boot lâu hơn sức chờ của proxy, người đó sẽ nhận về lỗi 504.\nVậy machine của tôi mất bao lâu để boot? Tôi chưa từng đo. Nhưng kho log thì biết, vì script khởi động có in ra từng giai đoạn, và dòng log nào cũng kèm machine id:\nWITH s AS ( SELECT fly.app.instance AS inst, timestamp, CASE WHEN message ILIKE \u0026#39;%Starting Granian%\u0026#39; THEN \u0026#39;begin\u0026#39; WHEN message ILIKE \u0026#39;%Migrations complete%\u0026#39; THEN \u0026#39;migrated\u0026#39; WHEN message LIKE \u0026#39;%\u0026#34;GET /api/health/%\u0026#39; THEN \u0026#39;serving\u0026#39; END AS phase FROM logs(\u0026#39;koreapost\u0026#39;, \u0026#39;2026-09-08\u0026#39;) ) SELECT inst, min(timestamp) FILTER (WHERE phase = \u0026#39;begin\u0026#39;) AS started, min(timestamp) FILTER (WHERE phase = \u0026#39;migrated\u0026#39;) AS migrated, min(timestamp) FILTER (WHERE phase = \u0026#39;serving\u0026#39;) AS first_serve FROM s GROUP BY inst Kết quả rất nhất quán trên mọi machine:\nGiai đoạn Thời gian Từ lúc boot đến khi chạy xong migration 10 đến 13 giây Từ lúc xong migration đến request đầu tiên được phục vụ 8 đến 19 giây Tổng 25 đến 33 giây Sau đó tôi đếm xem chuyện này xảy ra bao nhiêu lần:\nSELECT count(*) AS starts, count(DISTINCT fly.app.instance) AS machines FROM logs(\u0026#39;koreapost\u0026#39;, \u0026#39;2026-09-08\u0026#39;) WHERE message ILIKE \u0026#39;%Starting Granian%\u0026#39; -- starts: 92, machines: 10 Chín mươi hai lần cold start chỉ trong một ngày. Mỗi lần là một khoảng nửa phút, trong đó request nào được route tới machine đó cũng đều timeout. Đến đây thì con số hai mươi hai phần trăm hết còn bí ẩn.\nThủ phạm nằm ngay trong entrypoint của tôi # Đây là những gì mọi machine đều phải chạy trước khi chịu trả lời request:\nif [ \u0026#34;$1\u0026#34; = \u0026#34;granian-api\u0026#34; ]; then echo \u0026#34;Running migrations...\u0026#34; python manage.py migrate --noinput echo \u0026#34;Running collectstatic in background...\u0026#34; python manage.py collectstatic --noinput \u0026amp; exec granian --interface wsgi koreapost_project.wsgi:application ... fi Hãy đọc lại đoạn này, lần này nhớ trong đầu con số \u0026ldquo;92 lần mỗi ngày\u0026rdquo;.\nmigrate là một lần boot Django trọn vẹn: import mọi model, kết nối tới Postgres, query bảng migrations, rồi kết luận là chẳng có gì phải làm. Trên shared CPU, mất mười giây chỉ để không làm gì cả. collectstatic là lần boot Django trọn vẹn thứ hai, sau đó còn tải static file lên object storage, tranh giành đúng cái core duy nhất mà web server đang cố khởi động trên đó. Granian là lần boot thứ ba.\nBa trình thông dịch Python, hai trong số đó làm lại những việc đã xong từ trước, và chuyện này lặp lại mỗi khi một machine thức dậy.\nTôi viết như vậy là có lý do chính đáng, và có lẽ entrypoint của bạn trông na ná cũng vì lý do đó: migration thì phải chạy ở đâu đó, release_command của Fly từng bị treo với tôi, và entrypoint là nơi duy nhất chắc chắn được chạy khi deploy. Đặt việc của deploy vào đó là đúng. Có điều, đó không phải chỗ đúng để chạy việc đó mỗi lần machine khởi động, và một khi machine tự dừng rồi tự bật lại, hai sự kiện này không còn là một nữa.\nToàn bộ bug chỉ có vậy, và tôi nghĩ nó khá phổ biến:\nNhững việc thuộc về một lần release lại bị chạy lại mỗi lần machine khởi động. Scale-to-zero biến một sự kiện thành chín mươi hai sự kiện.\nCách sửa: mỗi release chỉ chuẩn bị đúng một lần # Migration không cần chạy lại trên machine thứ tám boot từ cùng một image. Nó chỉ cần chạy một lần cho image đó, và mọi machine khác cần biết là việc này đã xong.\nNói cách khác, ta cần một cái lock có tên thay đổi theo từng lần deploy. Fly cung cấp sẵn cái tên đó trong FLY_IMAGE_REF, còn Redis thì tôi đã có sẵn. Tất cả chỉ là một script nhỏ chạy trước khi Django được load, nên trong trường hợp thường gặp, nó chỉ tốn vài mili giây thay vì một lần boot cả framework:\ndef release_id() -\u0026gt; str: \u0026#34;\u0026#34;\u0026#34;Something that changes exactly when the deployed image does.\u0026#34;\u0026#34;\u0026#34; return os.getenv(\u0026#34;FLY_IMAGE_REF\u0026#34;) or os.getenv(\u0026#34;FLY_MACHINE_VERSION\u0026#34;) or \u0026#34;\u0026#34; def claim() -\u0026gt; int: release = release_id() if not release: return PREPARE client = _client() if client is None: # no Redis: behave exactly as before return PREPARE done_key, lock_key = _keys(release) if client.get(done_key): # somebody already did it for this image return SKIP owner = os.getenv(\u0026#34;FLY_MACHINE_ID\u0026#34;, \u0026#34;unknown\u0026#34;) if client.set(lock_key, owner, nx=True, ex=LOCK_TTL_SECONDS): return PREPARE # we won the race; we do the work # Someone else is preparing. Wait for them, because serving requests # against a half-migrated schema is worse than starting slowly. deadline = _monotonic() + WAIT_SECONDS while _monotonic() \u0026lt; deadline: _sleep(POLL_SECONDS) if client.get(done_key): return SKIP if not client.get(lock_key) and client.set(lock_key, owner, nx=True, ex=LOCK_TTL_SECONDS): return PREPARE # the owner died mid-migration; take over return PREPARE # waited long enough; do it ourselves Entrypoint giờ chỉ còn một câu if:\nif python3 koreapost_project/release_gate.py claim; then python manage.py migrate --noinput python manage.py collectstatic --noinput \u0026amp; python3 koreapost_project/release_gate.py done fi exec granian --interface wsgi koreapost_project.wsgi:application ... Mọi nhánh lỗi đều phải dẫn tới \u0026ldquo;cứ chuẩn bị đi\u0026rdquo; # Phần đáng học theo nằm ở đây, hơn là ở đoạn mã.\nMột cái gate như thế này đứng giữa người dùng và một lần migration schema. Nếu nó sai theo hướng bỏ qua, bạn sẽ phục vụ request trên một cơ sở dữ liệu chưa được migrate, và đó là một sự cố thật sự nghiêm trọng. Còn nếu nó sai theo hướng chuẩn bị, bạn chỉ chạy lại một migration idempotent vốn chẳng làm gì, và mất thêm mười giây.\nHai kết cục này không ngang nhau, nên mã nguồn không được xử lý chúng như nhau. Mọi nhánh không chắc chắn đều trả về PREPARE:\nKhông có Redis, hoặc không kết nối được tới Redis. Chuẩn bị. Đây chính là hành vi cũ, nên nếu cache có sập thì hệ thống chỉ \u0026ldquo;chậm\u0026rdquo; chứ không \u0026ldquo;hỏng\u0026rdquo;. Biến môi trường không có image reference. Chuẩn bị. Không biết đây là release nào thì cũng không thể biết nó đã sẵn sàng hay chưa. Machine đang giữ lock chết giữa chừng lúc chạy migration. Lock của nó sẽ hết hạn, và machine tiếp theo đang chờ sẽ giành lấy lock rồi chuẩn bị. Chờ quá thời gian timeout. Cứ chuẩn bị. Một machine không bao giờ phục vụ được còn tệ hơn một lần migration bị chạy trùng. Có một điểm bất đối xứng tôi cố ý đặt theo chiều ngược lại: machine nào thấy một machine khác đang giữ lock thì sẽ chờ chứ không phục vụ request. Khởi động chậm một chút trong lúc deploy thì không sao. Nhưng trả lời query trên một schema mới áp dụng được một nửa thì có sao đấy.\nNếu bạn dựng cơ chế này trên một wrapper cache kiểu \u0026ldquo;chịu lỗi\u0026rdquo; như tôi, hãy cẩn thận với một cái bẫy. Khi không kết nối được Redis, wrapper của tôi trả về False từ add(), trông y hệt kết quả \u0026ldquo;đã có machine khác giữ lock\u0026rdquo;, và nếu không để ý thì đã khiến mọi machine bỏ qua migration. Đó chính xác là hướng nguy hiểm. Hãy phân biệt hai trường hợp bằng cách đọc lại lock: nếu không ai giữ lock, nghĩa là cache đang hỏng, chứ không phải bạn thua cuộc đua.\nKiểm thử mà không cần Redis # Logic này đáng để viết unit test, vì những nhánh đáng chú ý nhất lại chính là những nhánh bạn không thể tái hiện bằng tay. Chỉ cần một bản fake có hai method là đủ:\nclass FakeRedis: \u0026#34;\u0026#34;\u0026#34;Enough of redis-py for the gate: get, and set with nx/ex.\u0026#34;\u0026#34;\u0026#34; def __init__(self) -\u0026gt; None: self.store: dict[str, str] = {} def get(self, key): return self.store.get(key) def set(self, key, value, nx=False, ex=None): if nx and key in self.store: return None self.store[key] = value return True Sau đó, dựng các trường hợp khó bằng cách tự điều khiển thời gian trôi. Lưu ý là gate gọi các alias _sleep và _monotonic ở cấp module chứ không gọi thẳng time.sleep, nên test có thể thay thế chúng mà không phải patch module time cho mọi thread khác trong process:\ndef test_a_waiter_takes_over_when_the_owner_disappears(self): self.assertEqual(release_gate.claim(), release_gate.PREPARE) # owner takes the lock _done, lock_key = release_gate._keys(release_gate.release_id()) def owner_dies(_seconds): self.redis.store.pop(lock_key, None) # its lock expired with patch.object(release_gate, \u0026#34;_sleep\u0026#34;, owner_dies): self.assertEqual(release_gate.claim(), release_gate.PREPARE) Có hiệu quả không? Được một phần # Deploy, dừng một machine, bật lại, rồi canh đồng hồ. Giờ đây lúc khởi động, script báo ra quyết định của nó rồi nhường đường luôn:\n15:23:34 Starting Granian (API server — HTTP/1.1 + HTTP/2)... 15:23:36 release gate: this release is already prepared, starting straight away 15:23:44 \u0026#34;GET /api/health/ HTTP/1.1\u0026#34; 200 Mười giây, và một machine thứ hai đo được mười một giây. So với 25 đến 33 giây trước đó, hai phần ba khoảng thời gian chết đã biến mất, và bản thân cái gate chỉ tốn hai giây vì nó không bao giờ import Django.\nNhưng mười giây vẫn là mười giây. Request nào tới trong những giây đầu tiên đó vẫn bị lỗi. Nên tôi đi tìm tiếp phần còn lại.\nBytecode không ai cache # Image có một dòng mà gần như Dockerfile Python nào cũng có:\nENV PYTHONDONTWRITEBYTECODE=1 Đây là lời khuyên đúng. Container không nên tự ghi các tệp .pyc vào layer lúc runtime. Nhưng tôi chưa bao giờ nghĩ kỹ về nửa còn lại của vấn đề: nếu lúc runtime không ghi bytecode, lúc build cũng không ghi, thì chẳng có gì được cache cả, và mỗi lần process khởi động là compile lại toàn bộ cây dependency từ mã nguồn.\nTôi thử đếm:\nsite-packages .py files : 5,707 site-packages .pyc files : 0 Django, DRF, mọi thư viện đều bị compile lại trong từng lần khởi động, 92 lần mỗi ngày. Đo trên một machine production:\ndjango.setup() Thời gian Bản đang chạy hiện tại 5.15s Sau khi chạy compileall 3.17s Cách sửa chỉ có một dòng, và vì compileall luôn ghi bytecode ra tệp, nên nó vẫn hoạt động bất kể biến môi trường kia:\nRUN python -m compileall -q /app/.venv/lib /app/koreapost_project /app/marketplace /app/utils || true Tốn thêm mười sáu giây lúc build, chỉ một lần, trong một layer được cache.\nNhân tiện, tôi còn phát hiện thêm một vấn đề âm thầm hơn. Tệp .dockerignore ghi:\n__pycache__/ *.pyc Docker so khớp các pattern này với toàn bộ đường dẫn tương đối, chứ không phải với từng thành phần của đường dẫn, nên một pattern không có neo sẽ chỉ khớp ở thư mục gốc của build context. Kết quả là mọi thư mục __pycache__ lồng bên trong đều bị đóng gói theo. Image chứa 218 tệp .pyc do Python 3.15 compile trên laptop, đi nhờ trong một image có trình thông dịch 3.14, và tất cả đều bị lặng lẽ bỏ qua. Pattern đúng phải là **/__pycache__/ và **/*.pyc.\nNói thật thì kết quả không như kỳ vọng: tính từ đầu đến cuối, cách này chỉ bớt được khoảng một giây chứ không phải hai. Mười giây còn chín. Benchmark đo riêng lẻ đã phóng đại, như benchmark đo riêng lẻ vẫn thường vậy.\nSuspend, và dòng cấu hình đang chặn nó # Đến mức chín giây thì tôi hết thứ để cắt bỏ. Phần còn lại là một lần boot không thể tránh: Python khởi động, Django import, rồi ứng dụng WSGI chạy lên trên một core dùng chung.\nVậy thì đừng boot nữa. Thay vì tắt hẳn machine, Fly có thể chụp snapshot bộ nhớ của nó:\n[http_service] auto_stop_machines = \u0026#39;suspend\u0026#39; # was \u0026#39;stop\u0026#39; Tôi thử bật lên thì Fly từ chối, kèm theo thông báo lỗi hữu ích nhất trong cả quá trình này:\nfailed to suspend VM: failed_precondition: Machines with swap cannot be suspended Gần đầu tệp cấu hình của tôi có đoạn này, kèm một comment do chính tôi viết và tin sái cổ:\n# Swap cushion so a memory spike swaps to disk instead of getting OOM-killed. swap_size_mb = 2048 Một biện pháp phòng ngừa hợp lý. Nhưng nó có thực sự làm gì không? Machine nào cũng báo:\nMemTotal: 985220 kB SwapTotal: 2097148 kB SwapFree: 2097148 kB Chưa từng có một page nào bị swap. Log trong ngày cũng không có vụ OOM kill nào — mười một dòng khớp với chữ \u0026ldquo;oom\u0026rdquo; hóa ra là request tới một bundle JavaScript mà content hash tình cờ chứa ba chữ cái đó. Trong khi đó, cơ chế bảo vệ bộ nhớ thật sự lại nằm ở một chỗ hoàn toàn khác, ngay trong lệnh chạy server: --workers-max-rss 800 sẽ khởi động lại worker từ lâu trước khi nó chạm tới trần 1GB.\nNói cách khác, cái đệm swap là bảo hiểm cho một sự cố chưa từng xảy ra, vốn đã có cơ chế khác lo, và phí bảo hiểm phải trả là mất khả năng suspend. Tôi bỏ nó đi.\nCách đánh thức Thời gian đến khi phục vụ được Cold boot, sáng nay 25 đến 33s Cold boot, sau khi thêm gate và cache bytecode 9 đến 10s Resume từ suspend 2.5 đến 3.1s Ba lần thử, các kết quả chênh nhau không quá nửa giây. Ngay sau khi thức dậy, endpoint nào cũng trả về 200 trong 250 đến 435 mili giây.\nSocket sẽ ra sao khi machine đang ngủ # Chẳng có gì tốt đẹp cả, mà cũng không có hook nào để xử lý. Fly đóng băng VM; process không nhận được signal nào và không kịp đóng thứ gì trước khi ngủ. Những kết nối nó đang giữ vẫn nằm nguyên trong bộ nhớ lúc thức dậy, trỏ tới những socket mà đầu bên kia đã bỏ đi từ mấy phút trước.\nBạn không xử lý chuyện này lúc machine đi ngủ, mà lúc nó thức dậy, và phần lớn là nhờ những quyết định đúng đã có từ trước:\nKết nối database được mở theo từng request (conn_max_age = 0), nên không có kết nối sống lâu nào để bị cũ. Redis cache chỉ xuống cấp chứ không ném exception. Một wrapper bắt lỗi kết nối và trả về cache miss. Tôi đã được tận mắt thấy cái thứ hai phát huy tác dụng. Một lần resume ghi ra đúng cái lỗi bạn đoán được:\nredis.exceptions.ConnectionError: Error while reading from fly-... và request gặp lỗi đó vẫn trả về 200 sau 3.6 giây, vì cache chết thì chỉ khiến trang chậm đi chứ không thành trang lỗi. Những lần resume sau đó thì log sạch trơn.\nNếu cache client của bạn ném exception khi gặp kết nối chết, suspend sẽ biến mỗi lần thức dậy thành một tràng lỗi 500. Hãy kiểm tra chuyện đó trước khi bật cấu hình này, đừng đợi đến sau.\nLần kiểm thử đã đánh lừa tôi # Lần đo suspend đầu tiên cho thấy tính năng này là một thảm họa: 502 sau 30 giây, hai lần liên tiếp.\nLúc đó tôi đã ghim request vào một machine cụ thể bằng header fly-force-instance-id. Header này rất tiện khi load test một instance, nhưng lại cực kỳ tệ cho trường hợp này. Ép vào một instance cụ thể sẽ bỏ qua phần logic của proxy vốn có nhiệm vụ đánh thức machine. Thật ra Fly đã nói rõ điều đó, nếu tôi chịu đọc nó như một câu trả lời thay vì một thông báo lỗi:\nmachine was recently stopped and is unavailable to service request Con số đáng quan tâm phải đến từ đường đi mà traffic thật đi qua. Tôi bắn bốn mươi request đồng thời qua edge, vượt xa soft limit là 12, để proxy buộc phải scale out sang các machine đang suspend:\nstatus codes: 40 × 200 slowest: 2.1s Có hai bài học, và bài thứ hai là bài đắt giá hơn. Hãy đo trên đường đi mà người dùng thật sự đi, chứ không phải đường đi tiện cho việc gắn công cụ đo. Và khi công cụ từ chối làm gì đó, hãy đọc kỹ lời từ chối: chính câu \u0026ldquo;Machines with swap cannot be suspended\u0026rdquo; đã gỡ nút thắt cho cả buổi chiều hôm đó.\nNếu là ứng dụng của bạn, tôi sẽ kiểm tra những gì # Nếu bạn đang chạy bất cứ thứ gì trên một nền tảng tự dừng instance khi rảnh, thì những câu hỏi dưới đây chỉ tốn của tôi một buổi chiều, nhưng lẽ ra đã giúp tôi tránh được mấy tuần lỗi âm thầm:\nMột lần cold start mất bao lâu? Không phải container mất bao lâu để chạy, mà là mất bao lâu đến khi nó trả lời được một request thật. Nếu bạn không trả lời được câu này bằng một con số tính theo giây, hãy đo ngay, trước khi thật sự cần tới nó. Entrypoint của bạn đang làm những việc gì mỗi lần khởi động mà lẽ ra thuộc về deploy? Migration, gom static file, warm cache, build index. Làm một lần thì không sao. Làm chín mươi hai lần thì rất tốn kém. Có thứ gì đang cache bytecode cho bạn không? Nếu có PYTHONDONTWRITEBYTECODE mà lúc build không chạy compileall, thì câu trả lời là không. Bạn có thể suspend thay vì stop không? Nếu không, thì cái gì đang cản bạn? Với tôi, đó là một swap file chưa từng được dùng tới. Edge có thấy những lỗi mà origin không thấy không? Hãy so sánh hai bên. Khoảng chênh giữa chúng không phải do báo cáo bị lệch, mà là request đang chết ở khoảng giữa, và log ứng dụng sẽ không bao giờ cho bạn thấy điều đó. À, nhân tiện, health check vẫn xanh suốt từ đầu đến cuối. Lúc nào chúng cũng xanh cả: health check chỉ chạy trên một machine đã khởi động xong. Nó không thể báo cáo gì về ba mươi giây trước khi machine đó tồn tại.\n","date":"09/09/2026","description":"22% request của tôi bị lỗi trong khi health check vẫn xanh. Lần theo nguyên nhân, tôi đi từ cold start mất 33 giây đến resume chỉ mất 3 giây.","externalUrl":null,"permalink":"/vi/posts/2026/2026-09-09-scale-to-zero-504s/","section":"Bài viết","summary":"","title":"Scale to zero, và mỗi lần khởi động lại là một lần sập 30 giây","type":"posts"},{"content":"Hiện tại, với vai trò là một solo developer, tôi đang vận hành đồng thời hai sản phẩm:\nmột ứng dụng field estimation dành cho các kỹ thuật viên/thợ dịch vụ cần ước tính và báo giá công việc trực tiếp tại hiện trường; và một nền tảng community marketplace có các tính năng như job listings, mua bán đồ đã qua sử dụng và real-time messaging. Hai sản phẩm khác nhau, nhóm người dùng cũng khác nhau, nhưng tech stack gần như giống hệt nhau — và cả hai đều đang chạy hoàn toàn trên các máy shared-cpu-1x, tier rẻ nhất mà Fly.io cung cấp.\nĐây là một giới hạn được tôi chủ động lựa chọn.\nThay vì sizing server theo peak load, tôi cố gắng tối ưu để khai thác tối đa hiệu năng của một máy nhỏ, đo chính xác ngưỡng mà hệ thống bắt đầu bottleneck hoặc collapse under load, rồi để autoscaler của Fly.io tự động scale out / scale in bằng cách thêm hoặc loại bớt machine dựa trên ngưỡng đã được đo đó.\nBài viết này sẽ đi vào các optimisation giúp mô hình đó khả thi, các số liệu load testing được dùng để xác định scaling threshold, và cách quá trình scale up / scale down thực tế hoạt động trên Fly.io.\nTriết lý: đo một máy, rồi nhân lên # Chiến lược scale của cả hai ứng dụng đều gồm ba bước giống nhau:\nTối ưu một máy nhỏ cho nhanh nhất trong khả năng hợp lý. Load test cho đến khi nó gãy, rồi ghi lại mức concurrency mà tại đó latency bắt đầu sụp. Cấu hình autoscaler của Fly để bật máy thứ hai ngay trước điểm đó, và dừng các máy đang rảnh khi traffic giảm. Khi đó, capacity tăng gần như tuyến tính theo số máy, còn chi phí nền chỉ là một VM nhỏ cho mỗi ứng dụng. Mọi thứ trong phần còn lại của bài đều phục vụ một trong ba bước này.\nTối ưu cho một máy nhỏ chạy nhanh # Granian: WSGI cho API, ASGI với uvloop cho WebSocket # Backend của cả hai sản phẩm đều là Django 6 + Django REST Framework. API chạy trên Granian, một server viết bằng Rust, nhưng dùng WSGI với blocking thread pool chứ không phải ASGI:\ngranian --interface wsgi project.wsgi:application \\ --workers 1 --blocking-threads 16 \\ --workers-max-rss 1400 \\ --workers-kill-timeout 15 --respawn-failed-workers Lúc đầu tôi dùng --interface asgi --loop uvloop, và đó là một quyết định sai. Mọi view HTTP trong hai ứng dụng này đều là sync, mà ASGI handler của Django lại chạy view sync ở chế độ thread_sensitive, tức mỗi worker chỉ xử lý được một view tại một thời điểm. Kết quả là với ASGI, mỗi worker chỉ xử lý được khoảng một request đồng thời, lại còn phải nạp nguyên stack asyncio/uvloop/Channels vào RAM mà chẳng được lợi gì. Còn WSGI với --blocking-threads thì chạy chính những view sync đó trong một thread pool thực thụ: một worker × 16 thread = 16 request I/O-bound đồng thời (đọc cơ sở dữ liệu, tạo presigned URL S3), mà bộ nhớ thường trú lại còn thấp hơn bản async.\nTại sao chỉ dùng một worker? Vì VM là shared-cpu-1x, tức chỉ có một core. Thêm worker thứ hai cũng không chạy Python song song được trên một core (GIL cộng với chỉ một CPU), nhưng lại tốn thêm nguyên một process Django thường trú trong bộ nhớ; chính Granian cũng cảnh báo đúng chuyện này. Với các tác vụ I/O-bound, mười sáu thread trên một worker là mức trần thực tế của một máy 1 vCPU. Tham số --workers-max-rss 1400 sẽ khởi động lại worker nếu nó phình quá giới hạn bộ nhớ, nên nếu một job tạo PDF hay xử lý ảnh chạy mất kiểm soát thì worker sẽ được thay mới, thay vì kéo cả máy đến chỗ OOM.\nWebSocket được tách ra chạy trong một process Granian riêng: một ứng dụng ASGI dùng uvloop và --ws, với một worker chuyên giữ các kết nối sống lâu. Phần API sync hoàn toàn không cần tới ASGI: khi một view cần đẩy dữ liệu xuống socket, nó gọi async_to_sync(channel_layer.group_send) qua Redis, và message vẫn đến được process WebSocket. Hai server, mỗi cái chạy đúng interface mà nó làm tốt nhất. Docker ở local cũng chạy cùng thread pool WSGI đó, nên khi kiểm thử, laptop của tôi mô phỏng khá sát một máy production.\nDùng Rust ở các hot path # Ở hot path nào của Python mà có sẵn một thư viện viết bằng Rust thay thế được ngay, tôi đều dùng:\norjson qua drf-orjson-renderer: mọi response của API đều được serialise bằng Rust thay vì JSON encoder của thư viện chuẩn. blurhash-rs: tạo placeholder cho ảnh ngay lúc tải lên. uuid-utils: sinh UUID bằng Rust. psycopg 3 (binary): driver Postgres được tăng tốc bằng C. Không thư viện nào trong số này làm thay đổi kiến trúc; chúng chỉ nâng mức trần của một vCPU lên, để bạn chưa phải thêm máy thứ hai sớm.\nLoại bỏ những khoản latency ẩn # Cải thiện lớn nhất trong cả hai codebase không đến từ thuật toán nào cả, mà từ việc tái sử dụng kết nối. Trong thread pool của Granian, mỗi thread mới lại tạo một S3 client boto3 mới toanh, và endpoint nào trả về ảnh hay PDF cũng phải gánh chi phí đó. Tôi viết một class riêng, SharedConnectionS3Storage, để cache client theo từng process:\nThời gian tạo signed URL trên các thread mới giảm từ ~150ms xuống ~1ms.\nVới cơ sở dữ liệu cũng vậy: Postgres trên Fly nằm sau PgBouncer, nên ứng dụng chạy với conn_max_age=0 và để pooler lo phần tái sử dụng kết nối, kèm connect_timeout 5 giây để khi gặp kết nối SSL đã chết thì báo lỗi ngay, chứ không làm treo cả worker.\nCache đúng chỗ đang thực sự nghẽn # Load test phía marketplace cho tôi một bài học đáng nhớ: mức trần nằm ở số kết nối Postgres, không phải CPU. Phần lớn traffic duyệt tin đến từ người dùng ẩn danh, và nội dung họ xem giống hệt nhau. Khi tải lên cao, những truy vấn danh sách giống hệt nhau đó làm đầy connection pool, trong khi CPU vẫn ngồi chơi.\nCách sửa là thêm một lớp cache Redis cho các trang danh sách mà người dùng ẩn danh xem, và invalidate cache bằng bộ đếm phiên bản theo từng namespace: mỗi thao tác ghi chỉ cần tăng một bộ đếm (O(1), không phải theo dõi từng key), thế là mọi trang đã cache của resource đó lập tức hết hiệu lực. Một TTL ngắn làm lưới an toàn cho những trường hợp bị sót. Trả trang duyệt tin thẳng từ Redis gỡ được đúng chỗ nghẽn thật, và để cái máy nhỏ dành kết nối cơ sở dữ liệu cho người dùng đã đăng nhập.\nChạy tác vụ nền mà không cần broker # Cả hai ứng dụng đều dùng django-tasks-db, framework task của Django lưu hàng đợi trong cơ sở dữ liệu, thay cho Celery. Một process db_worker riêng liên tục poll cơ sở dữ liệu để lấy job: xử lý ảnh, tạo PDF, dịch máy, gửi nhắc nhở. Không cần broker, không phải chọn cấu hình và giám sát thêm service nào, và process API không bao giờ bị chặn bởi những việc nặng. Worker còn kết nối thẳng tới Postgres, bỏ qua PgBouncer, vì các job chạy lâu không hợp với transaction pooling.\nBộ nhớ: jemalloc và swap để dự phòng # Xử lý ảnh và PDF làm bộ nhớ tăng vọt theo những cách mà khi tính toán cấu hình cho trạng thái ổn định bạn không lường trước được. Tôi dùng hai cách giảm thiểu rẻ tiền: preload jemalloc trong Docker image để giảm phân mảnh bộ nhớ, và bật swap trên mọi máy (2GB trên máy API, 1GB trên máy WebSocket), để khi bộ nhớ tăng vọt thì dữ liệu được đẩy xuống đĩa chứ process không bị OOM kill. Swap không phải tính năng giúp tăng hiệu năng; nó là một khoản bảo hiểm, cho phép tôi giữ mức cấp phát bộ nhớ ở mức nhỏ.\nLoad test: tìm điểm gãy # Một ngưỡng chỉ đáng tin khi nó đã được đo thực tế. Mỗi dự án có bộ công cụ kiểm thử riêng:\nỨng dụng field estimation có một stress test harness tự viết bằng Python (scripts/stress-test.py), chạy lần lượt qua các mức concurrency và báo cáo RPS, p50/p95/p99 cùng tỉ lệ lỗi. Đi kèm là mấy management command sinh ra hàng trăm job trông như thật, để các endpoint danh sách được truy vấn trên khối lượng dữ liệu sát thực tế. Marketplace dùng Locust với kiểu tải bậc thang, tăng từ 50 → 500 người dùng theo từng bậc 30 giây để tìm điểm bão hòa, với trọng số các endpoint khớp với traffic thực tế (phần lớn là duyệt tin). Ngoài ra còn một bộ k6 riêng, tăng từ 5 → 50 người dùng ảo, chạy thẳng vào production với ngưỡng p95 \u0026lt; 2s. Vài con số tiêu biểu từ lượt đo của ứng dụng field estimation (một worker Granian × 16 blocking thread, chạy Docker trên máy dev của tôi):\nEndpoint Concurrency RPS p95 Lỗi health 25 602 52ms 0% danh sách job 25 309 260ms 0% danh sách khách hàng 25 357 61ms 0% danh sách job 50 309 347ms 0% Còn con số quyết định mọi thứ khác đến từ đợt load test marketplace trên một máy shared-cpu-1x duy nhất. Với bản ASGI cũ, đây là một bức tường cứng: các view sync bị xếp hàng tuần tự, chỉ còn khoảng ba slot dùng được, nên p95 giữ dưới 300ms cho đến khoảng ~5 request đồng thời, rồi sụp hẳn khi vượt ~8–10, vì mọi request đều phải xếp hàng sau cái lock thread-sensitive. Chính việc chuyển API sang thread pool WSGI đã đẩy điểm gãy đó ra xa: 16 slot đồng thời thực sự trên mỗi máy thay vì ~3, và mức trần mới này là nền tảng cho toàn bộ cấu hình autoscaling.\nScale trên Fly.io hoạt động thế nào # Mô hình của Fly khá đơn giản: ứng dụng của bạn là một nhóm máy giống hệt nhau đứng sau một proxy, và proxy đếm tải trên từng máy. Bạn chỉ cần khai báo hai con số:\n[http_service.concurrency] type = \u0026#39;requests\u0026#39; soft_limit = 12 # past this, start another machine hard_limit = 24 # past this, shed load instead of queueing auto_stop_machines = \u0026#39;stop\u0026#39; auto_start_machines = true min_machines_running = 1 Scale up: khi số request đang xử lý trên một máy vượt soft limit, proxy sẽ bật một máy đang dừng và chuyển traffic mới sang đó. Thread pool WSGI cho mỗi máy 16 slot đồng thời thực sự, nên tôi đặt soft limit ở 12, tức khoảng 75% pool, để máy số 2 khởi động khi các thread vẫn còn dư sức, thay vì đợi đến lúc người dùng phải chờ tới vài giây. Hard limit 24 (gấp 1.5 lần pool) đóng vai trò cầu dao: những đợt tăng vọt ngắn thì xếp hàng chờ thread, còn vượt quá mức đó thì proxy sẽ từ chối bớt tải, thay vì để một máy bị quá tải tới mức sập. (Bản ASGI cũ chạy 8/20, tính theo ~3 slot thực của nó; chính việc nới rộng pool mới cho phép nâng cả hai con số lên.)\nScale down: khi traffic giảm, Fly tự động dừng các máy đang rảnh, xuống tới min_machines_running = 1. Máy đã dừng không tốn đồng tiền compute nào, chỉ tốn phần lưu trữ rootfs, nên chi phí nền của mỗi ứng dụng đúng nghĩa đen là một VM nhỏ, mà lúc nào cũng có sẵn capacity dự phòng cho những đợt tăng tải.\nBan đầu, ở đoạn này tôi có viết rằng một máy đã dừng \u0026ldquo;cold start mất chưa tới một giây\u0026rdquo;. Câu đó sai, và cái sai ấy khá đắt. Đến khi chịu đo thực tế thay vì đoán, tôi mới thấy một lần cold start mất 25 đến 33 giây, và bất cứ request nào proxy chuyển tới máy trong khoảng thời gian đó đều bị timeout ở edge: 22% số request trong một ngày, rơi đúng vào những endpoint mà ứng dụng di động poll định kỳ, trong khi mọi health check vẫn báo xanh.\nCách sửa không nằm ở mấy con số scale phía trên; chúng vẫn ổn. Vấn đề nằm ở những gì máy làm trong lúc khởi động, và ở việc yêu cầu Fly suspend máy thay vì stop, để mỗi lần đánh thức máy chỉ còn là một lần khôi phục bộ nhớ mất khoảng ~3 giây. Tôi đã viết riêng một bài về chuyện này: Scale về 0, và mỗi lần khởi động lại là 30 giây sập, nói về cách tôi chẩn đoán và những gì cần thay đổi. Nếu bạn đang chạy bất cứ thứ gì trên Fly với auto_stop_machines, hãy đọc bài đó trước khi tin vào đoạn này.\nCó một điểm tinh tế: kiểu concurrency là requests, không phải connections. Client hiện đại giữ kết nối keep-alive luôn mở, nên nếu đếm theo kết nối thì con số gần như đứng yên dù tải thực tế tăng, và autoscaler sẽ không bao giờ được kích hoạt. Đếm số request đang xử lý mới phản ánh đúng khối lượng công việc thực.\nWebSocket scale theo một trục khác # Marketplace tách server WebSocket ra thành một ứng dụng Fly thứ hai, dùng chung Docker image. Ứng dụng API chạy một worker × 16 blocking thread dưới WSGI và đếm theo request (soft limit 12). Ứng dụng WebSocket thì chạy 1 worker dưới ASGI với uvloop, trên một máy 512MB, và đếm theo kết nối:\n[http_service.concurrency] type = \u0026#39;connections\u0026#39; soft_limit = 1500 hard_limit = 2000 Giữ một socket sống lâu gần như không tốn gì, nhưng nó chiếm một slot hàng giờ liền, trong khi một request HTTP chỉ chiếm slot vài mili giây, nên không thể dùng chung một tín hiệu autoscaling cho cả hai. Tách riêng ra thì mỗi nhóm máy scale theo đúng thước đo phản ánh tải thật của nó, và capacity WebSocket lúc rảnh chỉ tốn 512MB chứ không phải 2GB. Về phía Channels, lớp Redis chạy với capacity: 1500, message hết hạn sau 60 giây, cộng thêm socket keepalive mỗi 30 giây, vì proxy Redis của Fly sẽ tự đóng các socket BRPOP sống lâu mà Channels cần tới khi chúng không hoạt động.\nĐẩy việc tính toán ra edge và sang phía client # Việc rẻ nhất cho server là việc server không bao giờ phải làm:\nTệp tĩnh và media nằm trên Tigris, dịch vụ lưu trữ tương thích S3 của Fly, được phân phối qua CDN của Tigris, với signed URL cho media riêng tư. Không một byte ảnh nào phải đi qua các máy Django. AI inference chạy trên điện thoại của người dùng. Cả hai ứng dụng đều nhúng một LLM chạy local qua llama.rn (marketplace stream một mô hình 4B đã quantize, ~2.7GB; ứng dụng field estimation thì nối tool-calling vào lớp dữ liệu để trợ lý tạo và cập nhật được job). Phía server không tốn đồng inference nào, và tính năng này chạy được cả khi offline. Ứng dụng di động offline-first (Expo SDK 57, SQLite trên thiết bị, Dexie trên web, một hàng đợi sync có retry) giúp ứng dụng gom các thao tác ghi lại thành từng đợt và chịu được những vùng mất sóng, thay vì dội vào API hàng loạt request lắt nhắt theo từng lần gõ phím. Phần còn lại của stack, điểm nhanh # Cả hai ứng dụng dùng Firebase Auth cộng với passkey WebAuthn, kèm một middleware tự viết để xác thực kết nối WebSocket trước khi upgrade. Postgres cho production, SQLite khi phát triển. django-unfold cho trang admin, django-simple-history để lưu audit trail. uv và ruff làm công cụ phát triển. Phía di động là React Native 0.86 / React 19 / TypeScript với Expo Router, Gluestack UI v3 và Tailwind 4, cập nhật OTA qua Expo Updates tự host, còn subscription thì qua RevenueCat. Khâu kiểm soát chất lượng: pytest + mypy + bandit ở backend, Jest + Detox + Playwright ở client.\nNhững gì bạn nên \u0026ldquo;học lỏm\u0026rdquo; # Điều đáng mang về không nằm ở một thư viện cụ thể nào, mà ở cả vòng lặp. Tối ưu cho một máy rẻ tiền chạy thật nhanh (dùng Rust ở hot path, tái sử dụng kết nối, đặt cache chắn trước chỗ nghẽn thật, đẩy được gì sang CDN và client thì đẩy). Load test cho đến khi tìm ra chính xác mức concurrency mà nó gãy. Đặt soft limit của autoscaler ngay tại điểm gãy đó, để các máy rảnh tự dừng, và giữ tối thiểu một máy luôn chạy. Hai ứng dụng production, có tính năng real-time, có trợ lý AI, mà hóa đơn hằng tháng lúc bình thường chỉ là vài VM rẻ nhất trên nền tảng.\n","date":"22/07/2026","description":"Cả hai ứng dụng production của tôi đều chạy trên những máy rẻ nhất của Fly.io. Bí quyết là tối ưu cho một máy nhỏ chạy thật nhanh, load test đến khi nó bắt đầu gục, rồi để autoscaler lo phần còn lại.","externalUrl":null,"permalink":"/vi/posts/2026/2026-07-22-my-current-stack/","section":"Bài viết","summary":"","title":"Tech stack hiện tại của tôi: vận hành hai sản phẩm trên lượng tài nguyên tối thiểu","type":"posts"},{"content":"Ứng dụng báo giá rèm cửa tôi đang làm có một yêu cầu không thể thương lượng: mất sóng vẫn phải chạy được. Báo giá được đo đạc và lập ngay trong lúc đi khảo sát tận nơi, mà khảo sát thì toàn ở nhà mới xây hoặc dưới tầng hầm, chỗ sóng điện thoại chỉ có trong lời đồn. Chính ràng buộc này buộc tôi phải trả lời nghiêm túc một câu hỏi mà phần lớn dự án React thường cho qua loa: loại state nào thì nên để ở đâu?\nSai lầm hay gặp nhất là dồn mọi thứ vào một store. Cache API, tuỳ chọn của người dùng, state của form, auth token, tất cả nhét chung vào một cục Redux. Nhưng đây là những loại state khác nhau, có vòng đời khác nhau, và mỗi loại hợp với một công cụ riêng. Zustand, React Query và AsyncStorage, mỗi cái giải quyết tốt đúng một trong các bài toán đó. Chỉ cần vạch rõ ranh giới giữa chúng là kiến trúc phần lớn sẽ tự vào khuôn.\nBa loại state # Trước khi tranh luận nên dùng library nào, hãy gọi cho đúng tên thứ mình đang quản lý đã.\nClient state chỉ tồn tại khi ứng dụng đang chạy: các nút bật/tắt trên giao diện, tab đang chọn, dữ liệu người dùng nhập vào form, modal đang mở hay đóng. Không có ai ở phía máy chủ sở hữu nó, và process tắt thì nó cũng mất theo.\nServer state thì khác. Máy chủ mới là chủ sở hữu, ứng dụng của bạn chỉ giữ một bản sao ở local. Ví dụ như hồ sơ người dùng, danh sách sản phẩm, thông báo, dữ liệu feed. Bản sao của bạn sẽ dần cũ đi, cần được fetch lại, và ngay lúc này rất có thể một client khác đang thấy một phiên bản khác của cùng dữ liệu đó.\nPersistent state là những gì phải còn nguyên sau khi khởi động lại ứng dụng: auth token, cờ đánh dấu người dùng đã xong onboarding, các tuỳ chọn đã lưu, dữ liệu dùng khi offline. Loại state này nằm ngay trên thiết bị.\nỨng dụng nào cũng có đủ cả ba loại. Và phần lớn mớ bòng bong đều bắt nguồn từ việc dùng một công cụ để xử lý cả ba.\nZustand: client state # Zustand là một library quản lý state nhỏ gọn cho React, không có mấy thủ tục rườm rà. Không cần provider, không boilerplate, không phải bọc năm lớp context chồng lên nhau. Bạn tạo một store, dùng nó trong component, vậy là xong bài học.\nNó là lựa chọn đúng khi nhiều component cần dùng chung một UI state (sidebar đang mở hay đóng, bộ lọc đang bật, các mục đang chọn), khi state ở cấp ứng dụng không đến từ API (theme, ngôn ngữ, feature flag đọc lúc khởi động), hoặc khi có logic phía client thật sự cần quản lý, như tính tiền giỏ hàng hay một wizard nhiều bước. Nói chung là những thứ cần cập nhật đồng bộ và dễ đoán.\nMột store cơ bản trông như thế này:\nimport { create } from \u0026#39;zustand\u0026#39; interface AppState { theme: \u0026#39;light\u0026#39; | \u0026#39;dark\u0026#39; sidebarOpen: boolean selectedFilters: string[] setTheme: (theme: \u0026#39;light\u0026#39; | \u0026#39;dark\u0026#39;) =\u0026gt; void toggleSidebar: () =\u0026gt; void setFilters: (filters: string[]) =\u0026gt; void } const useAppStore = create\u0026lt;AppState\u0026gt;((set) =\u0026gt; ({ theme: \u0026#39;light\u0026#39;, sidebarOpen: false, selectedFilters: [], setTheme: (theme) =\u0026gt; set({ theme }), toggleSidebar: () =\u0026gt; set((state) =\u0026gt; ({ sidebarOpen: !state.sidebarOpen })), setFilters: (filters) =\u0026gt; set({ selectedFilters: filters }), })) Còn đây là cách dùng trong component:\nfunction Sidebar() { const { sidebarOpen, toggleSidebar } = useAppStore() if (!sidebarOpen) return null return ( \u0026lt;div className=\u0026#34;sidebar\u0026#34;\u0026gt; \u0026lt;button onClick={toggleSidebar}\u0026gt;Close\u0026lt;/button\u0026gt; {/* sidebar content */} \u0026lt;/div\u0026gt; ) } Những thứ tôi không bỏ vào Zustand # Chủ yếu là response từ API. Tôi từng gặp nhiều dự án mà lời gọi API nào cũng ghi kết quả vào một store Zustand, còn component thì đọc từ store thay vì gọi query. Rốt cuộc bạn phải tự tay viết lại toàn bộ phần cache invalidation, trạng thái loading, xử lý lỗi, refetch và phân trang, trong khi đó lại đúng là những việc React Query sinh ra để làm thay bạn.\nThứ hai là bất cứ thứ gì phải còn lại sau khi khởi động lại. State của Zustand nằm trong bộ nhớ, đóng ứng dụng là mất. Middleware persist có thể nối Zustand với AsyncStorage (phần sau sẽ nói kỹ hơn), nhưng dùng nó phải là một quyết định có cân nhắc, đừng để thành thói quen.\nReact Query: server state # React Query (giờ đã đổi tên thành TanStack Query) quản lý toàn bộ vòng đời của dữ liệu từ xa: fetch, cache, đồng bộ, cập nhật và dọn rác. Điểm cần đổi tư duy là nó xem dữ liệu từ máy chủ như một bộ cache cần giữ cho mới, chứ không phải state của riêng bạn. Máy chủ mới là chủ. Bạn chỉ đang cầm một bản sao.\nTôi dùng nó cho mọi thứ lấy từ API. Chẳng hạn dữ liệu mà nhiều component cùng cần từ một endpoint (các request trùng nhau được tự động gộp lại), danh sách phân trang hoặc cuộn vô hạn, dữ liệu cần fetch lại ngầm khi người dùng quay lại ứng dụng, và optimistic update, tức là giao diện đổi ngay lập tức rồi rollback nếu máy chủ từ chối.\nimport { useQuery, useMutation, useQueryClient } from \u0026#39;@tanstack/react-query\u0026#39; function useUser(userId: string) { return useQuery({ queryKey: [\u0026#39;user\u0026#39;, userId], queryFn: () =\u0026gt; fetch(`/api/users/${userId}`).then(res =\u0026gt; res.json()), staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes }) } function useUpdateUser() { const queryClient = useQueryClient() return useMutation({ mutationFn: (data: { id: string; name: string }) =\u0026gt; fetch(`/api/users/${data.id}`, { method: \u0026#39;PATCH\u0026#39;, body: JSON.stringify(data), }), onSuccess: (_, variables) =\u0026gt; { queryClient.invalidateQueries({ queryKey: [\u0026#39;user\u0026#39;, variables.id] }) }, }) } Dùng trong component:\nfunction UserProfile({ userId }: { userId: string }) { const { data: user, isLoading, error } = useUser(userId) const updateUser = useUpdateUser() if (isLoading) return \u0026lt;Spinner /\u0026gt; if (error) return \u0026lt;ErrorMessage error={error} /\u0026gt; return ( \u0026lt;div\u0026gt; \u0026lt;h1\u0026gt;{user.name}\u0026lt;/h1\u0026gt; \u0026lt;button onClick={() =\u0026gt; updateUser.mutate({ id: userId, name: \u0026#39;New Name\u0026#39; })} disabled={updateUser.isPending} \u0026gt; Update Name \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; ) } Những thứ có sẵn mà bạn không phải tự viết # Tôi kiên quyết để dữ liệu từ máy chủ ở đây vì số cơ chế được tặng kèm miễn phí nhiều đến choáng ngợp. Năm component cùng lấy một user thì chỉ tạo ra đúng một network request. Dữ liệu tự fetch lại khi cửa sổ được focus trở lại hoặc khi có mạng lại. Cache cũ tự được dọn. Query nào cũng trả sẵn isLoading, isError, data cùng các trường liên quan, nên giao diện cho trạng thái loading và lỗi không còn phải làm thủ công từng chỗ. Request thất bại được retry với exponential backoff. Còn optimistic update kèm rollback là pattern có sẵn, chứ không phải mất nguyên buổi chiều tự đi dây.\nNhững việc không nên dùng React Query # Client state thuần tuý. Nếu dữ liệu không bao giờ đi qua máy chủ thì React Query chỉ thêm rườm rà. Trạng thái mở/đóng của một modal đâu cần cache invalidation hay refetch định kỳ.\nReact Query cũng không phải tầng lưu trữ lâu dài. Cache nằm trong bộ nhớ, nên mỗi lần khởi động lại là cache trống trơn và phải fetch lại một loạt. Bạn có thể persist cache (trên React Native thì nên làm, xem phần offline bên dưới), nhưng source of truth lúc nào cũng là máy chủ.\nAsyncStorage: những gì cần giữ lại sau khi khởi động lại # AsyncStorage là kho lưu trữ key-value của React Native. Trên web, thứ tương đương là localStorage (đồng bộ) hoặc IndexedDB (bất đồng bộ, làm được nhiều việc hơn). Dù dùng cái nào thì ý tưởng cũng như nhau: dữ liệu được ghi xuống thiết bị nên vẫn còn sau khi process kết thúc.\nBên dưới mỗi nền tảng thực chất là gì # Cùng một API, nhưng bên dưới là ba backend khá khác nhau (giả sử bạn dùng @react-native-async-storage/async-storage).\nTrên Android, bên dưới là SQLite, thông qua RKStorage, lưu trong một cơ sở dữ liệu nằm ở thư mục lưu trữ nội bộ của ứng dụng. Nhanh, ổn định, lại được sandbox nên ứng dụng khác không đụng vào được. Lưu ý giới hạn dung lượng mặc định trong bảng bên dưới.\nTrên iOS, giá trị nhỏ được lưu bằng NSUserDefaults, còn giá trị lớn hơn thì được serialise ra tệp, cũng nằm trong sandbox của ứng dụng. Apple không đặt giới hạn cứng cho NSUserDefaults, nhưng thông lệ hợp lý là giữ mỗi giá trị dưới vài trăm KB. Nếu dữ liệu của bạn lớn hơn thế thì có lẽ bạn nên dùng hẳn một cơ sở dữ liệu đàng hoàng như WatermelonDB hay Realm.\nTrên web (React Native Web hoặc Expo Web), AsyncStorage chuyển sang dùng localStorage, dung lượng tối đa khoảng 5-10 MB tuỳ trình duyệt. Ứng dụng React chỉ chạy trên web thì có thể dùng thẳng localStorage, hoặc dùng IndexedDB qua một wrapper như idb-keyval nếu dữ liệu lớn hơn.\nNền tảng Backend Giới hạn dung lượng Vị trí Android SQLite (RKStorage) Mặc định ~6 MB (chỉnh được) Bộ nhớ nội bộ của ứng dụng iOS NSUserDefaults / tệp Không có giới hạn cứng (nên giữ giá trị nhỏ) Sandbox của ứng dụng Web localStorage ~5-10 MB (tuỳ trình duyệt) Bộ nhớ theo origin của trình duyệt Tôi lưu gì trong đó # Auth token và dữ liệu phiên đăng nhập, các tuỳ chọn cần giữ lại (ngôn ngữ, theme, cài đặt thông báo), cờ đã xong onboarding, dữ liệu cache để dùng khi offline. Tóm lại là những mẩu dữ liệu key-value nho nhỏ cần giữ lại sau khi khởi động lại, chỉ vậy thôi.\nAPI của nó đơn giản hết mức mà một kho lưu trữ có thể có:\nimport AsyncStorage from \u0026#39;@react-native-async-storage/async-storage\u0026#39; // Store a value await AsyncStorage.setItem(\u0026#39;auth_token\u0026#39;, token) // Read a value const token = await AsyncStorage.getItem(\u0026#39;auth_token\u0026#39;) // Store an object (must serialize) await AsyncStorage.setItem(\u0026#39;user_preferences\u0026#39;, JSON.stringify({ theme: \u0026#39;dark\u0026#39;, language: \u0026#39;en\u0026#39;, notifications: true, })) // Read an object const prefs = JSON.parse(await AsyncStorage.getItem(\u0026#39;user_preferences\u0026#39;) ?? \u0026#39;{}\u0026#39;) // Remove a value await AsyncStorage.removeItem(\u0026#39;auth_token\u0026#39;) // Clear everything (careful with this) await AsyncStorage.clear() Phiên bản cho web # Ứng dụng React chỉ chạy trên web thì không cần đến AsyncStorage. Các trường hợp đơn giản dùng localStorage là đủ:\n// Synchronous - blocks the main thread, but fine for small data localStorage.setItem(\u0026#39;theme\u0026#39;, \u0026#39;dark\u0026#39;) const theme = localStorage.getItem(\u0026#39;theme\u0026#39;) // For structured data localStorage.setItem(\u0026#39;user\u0026#39;, JSON.stringify({ name: \u0026#39;Jared\u0026#39;, role: \u0026#39;admin\u0026#39; })) const user = JSON.parse(localStorage.getItem(\u0026#39;user\u0026#39;) ?? \u0026#39;{}\u0026#39;) Còn dữ liệu lớn hơn thì dùng IndexedDB:\nimport { get, set, del } from \u0026#39;idb-keyval\u0026#39; await set(\u0026#39;large-dataset\u0026#39;, hugeArray) const data = await get(\u0026#39;large-dataset\u0026#39;) await del(\u0026#39;large-dataset\u0026#39;) Những việc không nên dùng AsyncStorage # AsyncStorage là kho key-value, không phải cơ sở dữ liệu. Dữ liệu quan hệ, mảng hàng nghìn phần tử, bất cứ thứ gì cần đánh index hay query thì hãy để cho SQLite (qua expo-sqlite), WatermelonDB hoặc Realm.\nNó cũng không phải kho lưu trữ bảo mật. Trên thiết bị đã root hoặc jailbreak, ai cũng đọc được nội dung AsyncStorage. Token nhạy cảm phải để trong expo-secure-store hoặc react-native-keychain.\nKết hợp cả ba với nhau # Ứng dụng thực tế thường dùng cả ba cùng lúc. Dưới đây là vài pattern tôi dùng thường xuyên.\nLuồng đăng nhập # // 1. AsyncStorage: persist the auth token import AsyncStorage from \u0026#39;@react-native-async-storage/async-storage\u0026#39; async function saveToken(token: string) { await AsyncStorage.setItem(\u0026#39;auth_token\u0026#39;, token) } async function getToken(): Promise\u0026lt;string | null\u0026gt; { return AsyncStorage.getItem(\u0026#39;auth_token\u0026#39;) } // 2. Zustand: track auth state in memory import { create } from \u0026#39;zustand\u0026#39; interface AuthState { isAuthenticated: boolean token: string | null setAuth: (token: string) =\u0026gt; void clearAuth: () =\u0026gt; void } const useAuthStore = create\u0026lt;AuthState\u0026gt;((set) =\u0026gt; ({ isAuthenticated: false, token: null, setAuth: (token) =\u0026gt; set({ isAuthenticated: true, token }), clearAuth: () =\u0026gt; set({ isAuthenticated: false, token: null }), })) // 3. React Query: fetch user profile using the token function useCurrentUser() { const token = useAuthStore((s) =\u0026gt; s.token) return useQuery({ queryKey: [\u0026#39;currentUser\u0026#39;], queryFn: () =\u0026gt; fetch(\u0026#39;/api/me\u0026#39;, { headers: { Authorization: `Bearer ${token}` }, }).then(res =\u0026gt; res.json()), enabled: !!token, // Only fetch when we have a token }) } Còn lúc ứng dụng khởi động:\n// App initialization async function initializeApp() { const token = await getToken() // Read from AsyncStorage if (token) { useAuthStore.getState().setAuth(token) // Put in Zustand for quick access // React Query will automatically fetch the user profile } } AsyncStorage giữ token qua các lần khởi động lại, Zustand giúp đọc token ở bất cứ đâu mà không tốn kém, còn React Query fetch hồ sơ người dùng ngay khi có token. Mỗi công cụ chỉ làm đúng một việc mà nó giỏi nhất.\nTheme vẫn giữ nguyên sau khi khởi động lại # import { create } from \u0026#39;zustand\u0026#39; import { persist, createJSONStorage } from \u0026#39;zustand/middleware\u0026#39; import AsyncStorage from \u0026#39;@react-native-async-storage/async-storage\u0026#39; // Zustand with persistence middleware bridges the gap const useThemeStore = create( persist( (set) =\u0026gt; ({ theme: \u0026#39;light\u0026#39; as \u0026#39;light\u0026#39; | \u0026#39;dark\u0026#39;, toggleTheme: () =\u0026gt; set((state) =\u0026gt; ({ theme: state.theme === \u0026#39;light\u0026#39; ? \u0026#39;dark\u0026#39; : \u0026#39;light\u0026#39;, })), }), { name: \u0026#39;theme-storage\u0026#39;, storage: createJSONStorage(() =\u0026gt; AsyncStorage), // React Native // storage: createJSONStorage(() =\u0026gt; localStorage), // Web } ) ) Zustand quản lý state trong lúc ứng dụng chạy, còn middleware persist âm thầm đồng bộ state đó xuống AsyncStorage (hoặc localStorage trên web). Component của bạn không biết, mà cũng chẳng cần biết, là có một tầng lưu trữ nằm bên dưới.\nĐọc dữ liệu theo kiểu offline-first # import { useQuery } from \u0026#39;@tanstack/react-query\u0026#39; import AsyncStorage from \u0026#39;@react-native-async-storage/async-storage\u0026#39; function useProducts() { return useQuery({ queryKey: [\u0026#39;products\u0026#39;], queryFn: async () =\u0026gt; { try { const res = await fetch(\u0026#39;/api/products\u0026#39;) const data = await res.json() // Cache in AsyncStorage for offline use await AsyncStorage.setItem(\u0026#39;cached_products\u0026#39;, JSON.stringify(data)) return data } catch (error) { // Network failed - try cached data const cached = await AsyncStorage.getItem(\u0026#39;cached_products\u0026#39;) if (cached) return JSON.parse(cached) throw error } }, staleTime: 10 * 60 * 1000, }) } React Query lo việc fetch và cache trong bộ nhớ, còn AsyncStorage là phương án dự phòng khi mất mạng.\nLàm chế độ offline, từng bước một # Đây mới là phần tôi thật sự quan tâm. Người ta hay nói người dùng mở ứng dụng trên tàu điện ngầm, trong thang máy, trên máy bay, và đúng là vậy. Nhưng trường hợp của tôi thì yêu cầu thẳng thừng hơn nhiều: báo giá được lập ngay tại công trình, và tôi không thể mặc định là công trình có sóng. Một cái spinner quay mãi dưới tầng hầm đồng nghĩa với mất một đơn báo giá.\nĐiều bất ngờ dễ chịu là chỉ với ba library này, bạn đã dựng được một hệ thống offline thật sự vững vàng mà không cần đến framework cồng kềnh nào.\nBước 1: biết lúc nào đang offline # Thiết bị cần báo cho ứng dụng biết tình trạng mạng, và ứng dụng cần một chỗ để lưu thông tin đó. @react-native-community/netinfo cung cấp các event, còn một store Zustand bé xíu giúp đọc được trạng thái đó từ bất cứ đâu.\nimport { create } from \u0026#39;zustand\u0026#39; import NetInfo from \u0026#39;@react-native-community/netinfo\u0026#39; interface NetworkState { isOnline: boolean setOnline: (online: boolean) =\u0026gt; void } const useNetworkStore = create\u0026lt;NetworkState\u0026gt;((set) =\u0026gt; ({ isOnline: true, setOnline: (online) =\u0026gt; set({ isOnline: online }), })) // Subscribe once at app startup NetInfo.addEventListener((state) =\u0026gt; { useNetworkStore.getState().setOnline(state.isConnected ?? false) }) Từ giờ, component nào cũng có thể gọi useNetworkStore((s) =\u0026gt; s.isOnline) để hiện banner báo offline, vô hiệu hoá nút gửi, hoặc báo cho người dùng biết thay đổi sẽ được đồng bộ sau.\nBước 2: cấu hình React Query cho chế độ offline # React Query có sẵn cơ chế xử lý offline thông qua networkMode, quyết định query và mutation sẽ làm gì khi mất mạng.\nimport { QueryClient } from \u0026#39;@tanstack/react-query\u0026#39; const queryClient = new QueryClient({ defaultOptions: { queries: { networkMode: \u0026#39;offlineFirst\u0026#39;, // Return cached data immediately, then refetch in background when online staleTime: 5 * 60 * 1000, gcTime: 24 * 60 * 60 * 1000, // Keep cache for 24 hours retry: (failureCount, error) =\u0026gt; { // Don\u0026#39;t retry if we\u0026#39;re offline - it\u0026#39;ll just fail again if (!useNetworkStore.getState().isOnline) return false return failureCount \u0026lt; 3 }, }, mutations: { networkMode: \u0026#39;offlineFirst\u0026#39;, }, }, }) Có ba chế độ:\nChế độ Hành vi online (mặc định) Query chỉ chạy khi có mạng. Mất mạng thì tạm dừng. always Query chạy bất kể có mạng hay không. queryFn của bạn phải tự xử lý lỗi. offlineFirst Query chạy một lần (để lấy dữ liệu cache), rồi tạm dừng, đợi có mạng mới fetch lại. Với phần lớn ứng dụng di động, offlineFirst là lựa chọn phù hợp: dữ liệu cache hiện ra ngay, còn dữ liệu mới sẽ về khi có mạng.\nBước 3: persist query cache # Mặc định, cache chỉ nằm trong bộ nhớ, nên mỗi lần khởi động lại là spinner quay khắp màn hình. Muốn có chế độ offline thì bạn persist nó xuống AsyncStorage:\nimport { QueryClient } from \u0026#39;@tanstack/react-query\u0026#39; import { PersistQueryClientProvider } from \u0026#39;@tanstack/react-query-persist-client\u0026#39; import { createAsyncStoragePersister } from \u0026#39;@tanstack/query-async-storage-persister\u0026#39; import AsyncStorage from \u0026#39;@react-native-async-storage/async-storage\u0026#39; const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 24 * 60 * 60 * 1000, // 24 hours - must be \u0026gt;= maxAge }, }, }) const asyncStoragePersister = createAsyncStoragePersister({ storage: AsyncStorage, key: \u0026#39;react-query-cache\u0026#39;, }) // In your App component function App() { return ( \u0026lt;PersistQueryClientProvider client={queryClient} persistOptions={{ persister: asyncStoragePersister, maxAge: 24 * 60 * 60 * 1000, // Don\u0026#39;t restore data older than 24 hours dehydrateOptions: { shouldDehydrateQuery: (query) =\u0026gt; { // Only persist successful queries return query.state.status === \u0026#39;success\u0026#39; }, }, }} \u0026gt; \u0026lt;YourApp /\u0026gt; \u0026lt;/PersistQueryClientProvider\u0026gt; ) } Giờ thì dù mở ứng dụng ở chỗ không có sóng, người dùng vẫn thấy ngay dữ liệu fetch lần gần nhất thay vì một màn hình trắng, và khi có mạng lại thì React Query lặng lẽ fetch dữ liệu mới.\nBước 4: xếp hàng các thao tác ghi khi offline # Đọc là phần dễ. Ghi mới là chỗ offline trở nên thú vị: người dùng thêm bình luận, gửi đơn hàng hay sửa hồ sơ lúc không có mạng, và thay đổi đó phải được xếp vào hàng đợi để gửi lại sau.\nReact Query giải quyết chuyện này bằng useMutation kèm optimistic update trong onMutate:\nimport { useMutation, useQueryClient } from \u0026#39;@tanstack/react-query\u0026#39; import AsyncStorage from \u0026#39;@react-native-async-storage/async-storage\u0026#39; interface Comment { id: string text: string postId: string createdAt: string pending?: boolean } function useAddComment(postId: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: async (text: string) =\u0026gt; { const res = await fetch(`/api/posts/${postId}/comments`, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, body: JSON.stringify({ text }), }) return res.json() }, // Optimistic update - show the comment immediately onMutate: async (text) =\u0026gt; { await queryClient.cancelQueries({ queryKey: [\u0026#39;comments\u0026#39;, postId] }) const previous = queryClient.getQueryData\u0026lt;Comment[]\u0026gt;([\u0026#39;comments\u0026#39;, postId]) const optimisticComment: Comment = { id: `temp-${Date.now()}`, text, postId, createdAt: new Date().toISOString(), pending: true, // Show a \u0026#34;sending...\u0026#34; indicator in the UI } queryClient.setQueryData\u0026lt;Comment[]\u0026gt;( [\u0026#39;comments\u0026#39;, postId], (old) =\u0026gt; [...(old ?? []), optimisticComment] ) return { previous } }, // Roll back on failure onError: (err, text, context) =\u0026gt; { if (context?.previous) { queryClient.setQueryData([\u0026#39;comments\u0026#39;, postId], context.previous) } }, // Refetch to get the real data from the server onSettled: () =\u0026gt; { queryClient.invalidateQueries({ queryKey: [\u0026#39;comments\u0026#39;, postId] }) }, }) } Khi đặt networkMode: 'offlineFirst' cho mutation, mutationFn sẽ tạm dừng cho tới khi có mạng trở lại. Optimistic update đưa bình luận lên màn hình ngay lập tức, còn lời gọi API thật sẽ chạy khi kết nối lại.\nNếu cần một hàng đợi nghiêm túc hơn, chẳng hạn có vài chục thay đổi đang chờ và phải gửi lại đúng thứ tự, bạn có thể persist luôn cả hàng đợi mutation:\nimport { MutationCache } from \u0026#39;@tanstack/react-query\u0026#39; // Save pending mutations to AsyncStorage const mutationCache = new MutationCache({ onError: async (error, variables, context, mutation) =\u0026gt; { // Log failed mutations for debugging const pending = JSON.parse( await AsyncStorage.getItem(\u0026#39;pending_mutations\u0026#39;) ?? \u0026#39;[]\u0026#39; ) pending.push({ key: mutation.options.mutationKey, variables, timestamp: Date.now(), }) await AsyncStorage.setItem(\u0026#39;pending_mutations\u0026#39;, JSON.stringify(pending)) }, }) Bước 5: đồng bộ lại khi có mạng # Phần lớn việc xử lý khi kết nối lại thì React Query tự lo: mutation đang tạm dừng sẽ chạy tiếp, query đã cũ sẽ được fetch lại. Bạn chỉ cần nối các manager của nó với event của React Native.\nimport NetInfo from \u0026#39;@react-native-community/netinfo\u0026#39; import { onlineManager, focusManager } from \u0026#39;@tanstack/react-query\u0026#39; import { AppState } from \u0026#39;react-native\u0026#39; // Tell React Query about network state changes onlineManager.setEventListener((setOnline) =\u0026gt; { return NetInfo.addEventListener((state) =\u0026gt; { setOnline(!!state.isConnected) }) }) // Refetch when the app comes back to foreground focusManager.setEventListener((setFocused) =\u0026gt; { const subscription = AppState.addEventListener(\u0026#39;change\u0026#39;, (status) =\u0026gt; { setFocused(status === \u0026#39;active\u0026#39;) }) return () =\u0026gt; subscription.remove() }) Khi người dùng để ứng dụng chạy nền một tiếng rồi mở lại, focusManager sẽ kích hoạt refetch để họ thấy dữ liệu mới. Khi họ ra khỏi đường hầm và có sóng trở lại, onlineManager cho những thao tác đang tạm dừng chạy tiếp.\nBước 6: thông báo rõ trên giao diện # Dù làm gì thì cũng đừng lẳng lặng nuốt lỗi mạng. Hãy cho người dùng biết họ đang offline và thay đổi nào vẫn đang chờ gửi.\nimport { useNetworkStore } from \u0026#39;./stores/network\u0026#39; function OfflineBanner() { const isOnline = useNetworkStore((s) =\u0026gt; s.isOnline) if (isOnline) return null return ( \u0026lt;View style={styles.banner}\u0026gt; \u0026lt;Text\u0026gt;You\u0026#39;re offline. Changes will sync when you reconnect.\u0026lt;/Text\u0026gt; \u0026lt;/View\u0026gt; ) } function CommentItem({ comment }: { comment: Comment }) { return ( \u0026lt;View style={[styles.comment, comment.pending \u0026amp;\u0026amp; styles.pending]}\u0026gt; \u0026lt;Text\u0026gt;{comment.text}\u0026lt;/Text\u0026gt; {comment.pending \u0026amp;\u0026amp; ( \u0026lt;Text style={styles.pendingLabel}\u0026gt;Sending...\u0026lt;/Text\u0026gt; )} \u0026lt;/View\u0026gt; ) } Bức tranh toàn cảnh # ┌─────────────────────────────────────────────────┐ │ Components │ │ useQuery() for reads useMutation() for writes│ └──────────┬──────────────────────┬────────────────┘ │ │ ┌─────▼──────┐ ┌─────▼──────┐ │ React Query │ │ React Query │ │ Cache │ │ Mutation │ │ (in-memory) │ │ Queue │ └─────┬──────┘ └─────┬──────┘ │ │ ┌─────▼──────────────────────▼──────┐ │ AsyncStorage Persister │ │ (survives app restart) │ └─────┬──────────────────────┬──────┘ │ │ ┌─────▼──────┐ ┌─────▼──────┐ │ Zustand │ │ Network │ │ (isOnline, │◄───────│ NetInfo │ │ UI state) │ │ │ └────────────┘ └────────────┘ Lớp Công cụ Vai trò Phát hiện mạng Zustand + NetInfo Theo dõi online/offline, điều khiển banner trên giao diện Fetch dữ liệu React Query Fetch khi có mạng, trả dữ liệu cache khi offline Persist cache React Query + AsyncStorage Khôi phục cache khi khởi động lại ứng dụng Ghi khi offline Mutation của React Query Xếp hàng mutation, gửi lại khi có mạng Optimistic UI onMutate của React Query Hiện thay đổi ngay, rollback nếu thất bại Đồng bộ khi ứng dụng được focus focusManager của React Query Fetch lại dữ liệu cũ khi ứng dụng quay lại foreground Khi nào cần một sync engine thực thụ # Tất cả những gì ở trên đều giả định máy chủ là source of truth và offline chỉ là tạm thời. Nếu bạn cần offline-first thật sự, có cả giải quyết xung đột (hình dung một ứng dụng ghi chú mà hai thiết bị cùng sửa một tài liệu lúc offline), thì stack này không kham nổi, và bạn cần một sync engine đúng nghĩa. WatermelonDB được làm riêng cho React Native, chạy trên SQLite và có sẵn giao thức sync để giải quyết xung đột. Realm kết hợp Atlas Device Sync là một cơ sở dữ liệu offline-first đầy đủ, tự động giải quyết xung đột qua MongoDB Atlas. PowerSync là một tầng sync dựa trên SQLite, chạy được với backend Postgres sẵn có của bạn. Còn nếu muốn toàn quyền kiểm soát thì bạn luôn có thể dùng Expo SQLite và tự viết logic sync.\nBộ ba Zustand + React Query + AsyncStorage đáp ứng được khoảng 80% ứng dụng di động. 20% còn lại, như các trình soạn thảo cộng tác hay những quy trình làm việc offline nặng trên nhiều thiết bị, thì cần một sync database chuyên dụng.\nMột mẩu state nên để ở đâu? # Khi phân vân không biết một thứ nên để ở đâu, tôi lần lượt hỏi mấy câu sau:\nCâu hỏi Nếu có → dùng Có đến từ máy chủ/API không? React Query Có phải UI state chỉ ở phía client và được nhiều component dùng chung không? Zustand Có cần giữ lại sau khi khởi động lại ứng dụng không? AsyncStorage (+ Zustand persist nếu cần) Có nhạy cảm không (token, mật khẩu)? expo-secure-store / react-native-keychain Có phải dữ liệu có cấu trúc, dung lượng lớn và cần query không? SQLite / WatermelonDB / Realm Có phải một input form đơn giản, chỉ một component dùng không? useState Các trường hợp thường gặp # State Công cụ Lý do Dữ liệu response từ API React Query Cache, gộp request trùng, refetch, trạng thái loading Tab đang chọn / bộ lọc đang bật Zustand Chỉ ở client, nhiều component cần dùng Auth token AsyncStorage + Zustand Giữ qua các lần khởi động lại, đọc nhanh từ bộ nhớ Tuỳ chọn theme Zustand với middleware persist Client state cần giữ lại sau khi khởi động lại Giỏ hàng Zustand với middleware persist Logic phía client phức tạp, cần giữ lại sau khi khởi động lại Input form useState Chỉ một component, không cần chia sẻ Hồ sơ người dùng từ API React Query Server state, có thể đã cũ Cờ đã xong onboarding AsyncStorage Chỉ là một giá trị boolean cần lưu lại Feed được cache để xem offline React Query + AsyncStorage Fetch từ máy chủ, lỗi thì quay về dùng cache Cài đặt theo từng nền tảng # React Native (Android + iOS) # Cài cả ba một lượt:\nnpm install zustand @tanstack/react-query @react-native-async-storage/async-storage Thêm kho lưu trữ bảo mật cho những dữ liệu nhạy cảm:\nnpx expo install expo-secure-store # or npm install react-native-keychain Expo # Với Expo, AsyncStorage chạy được ngay, không cần native linking.\nnpx expo install @react-native-async-storage/async-storage React chỉ chạy trên web # Bỏ hẳn AsyncStorage, dùng thẳng localStorage hoặc IndexedDB.\nnpm install zustand @tanstack/react-query # Optional for IndexedDB npm install idb-keyval Middleware persist của Zustand vốn đã dùng localStorage làm mặc định trên web:\npersist(storeConfig, { name: \u0026#39;my-store\u0026#39;, // localStorage is the default on web - no extra config needed }) Những lỗi tôi gặp đi gặp lại # Bỏ dữ liệu API vào Zustand. Nếu trong action của bạn có dòng setUsers(apiResponse.users) thì dừng lại. Đó là việc của React Query, và chỉ một sprint nữa thôi là bạn sẽ tự chế lại cache invalidation, mà còn chế dở.\nDùng React Query cho client state. Nếu queryFn không hề gửi network request nào thì bạn đang dùng sai công cụ.\nDùng AsyncStorage như cơ sở dữ liệu. Serialise một mảng 10,000 phần tử vào kho key-value thì kết cục chỉ có khóc. Hãy dùng SQLite hoặc một cơ sở dữ liệu thật sự.\nLưu token mà không mã hoá. AsyncStorage không phải kho lưu trữ bảo mật. Auth token, API key và thông tin đăng nhập phải để trong expo-secure-store hoặc keychain của nền tảng.\nTự viết tay phần persist. Nếu bạn đang đọc AsyncStorage lúc component mount và ghi xuống mỗi khi state thay đổi, thì middleware persist của Zustand đã làm sẵn việc đó, lại lo luôn cả hydration và serialise, mã ít hơn mà lỗi cũng ít hơn.\nTóm lại: dữ liệu từ máy chủ đi qua React Query. Client state nằm trong Zustand. Thứ gì cần giữ lại sau khi khởi động lại thì cho vào AsyncStorage, còn thứ gì nhạy cảm thì cho vào kho lưu trữ bảo mật.\nNhững kiến trúc tệ nhất tôi từng làm đều có một store khổng lồ mà mọi dữ liệu đều chảy qua. Những kiến trúc tốt nhất thì phân định rõ ràng giữa server state, client state và những gì cần lưu lại lâu dài. Vạch sẵn các ranh giới đó từ sớm thì mọi thứ về sau sẽ dễ thở hơn nhiều.\n","date":"05/05/2026","description":"Cách tôi phân chia state giữa Zustand, React Query và AsyncStorage trong React và React Native, và cách kết hợp cả ba để chế độ offline thật sự chạy ổn.","externalUrl":null,"permalink":"/vi/posts/2026/2026-05-05-zustand-vs-react-query/","section":"Bài viết","summary":"","title":"Zustand, React Query hay AsyncStorage: state nào nên để ở đâu?","type":"posts"},{"content":"Mấy tuần vừa rồi tôi dồn sức cho hai dự án có liên quan đến nhau. Dự án đầu tiên là tái hiện lại một bài báo năm 2024 đăng trên Scientific Reports của Lee và cộng sự, tựa đề “Social signals predict contemporary art prices better than visual features, particularly in emerging markets” (tạm dịch: tín hiệu xã hội dự đoán giá nghệ thuật đương đại tốt hơn đặc điểm thị giác, nhất là ở các thị trường mới nổi). Nhóm tác giả train một mô hình XGBoost trên 34,200 bản ghi đấu giá của 590 nghệ sĩ còn sống. Dự án thứ hai là Art Evaluator, một nền tảng viết bằng Django và Expo, có knowledge graph (đồ thị tri thức) nối nghệ sĩ, chủ sở hữu và các bản ghi đấu giá với nhau. Nói nôm na, nếu muốn đem một mô hình như của bài báo ra làm việc gì có ích thì bạn sẽ cần đến phần hạ tầng dữ liệu bao quanh nó, và Art Evaluator chính là phần đó.\nLàm cả hai, tôi cứ bị kéo về cùng một câu hỏi: liệu có đoán trước được một tác phẩm sẽ bán với giá bao nhiêu không? Câu trả lời là có, nhưng không phải nhờ nhìn vào tác phẩm. Những feature thực sự có tác dụng gần như đều mang tính xã hội.\nVì sao định giá nghệ thuật lại khó # Trước khi viết dòng mã nào, tôi bỏ vài ngày đọc xem giám định viên và nhà đấu giá định giá tác phẩm như thế nào. Danh sách yếu tố dài dằng dặc, mà phần lớn lại chẳng quy ra số liệu gọn gàng được. Đầu tiên là provenance, tức lịch sử nguồn gốc: ai từng sở hữu, từng trưng bày ở đâu, được catalogue nào nhắc tới. Rồi đến chuyện xác nhận tác giả: một tác phẩm được quỹ của nghệ sĩ xác nhận có thể đắt gấp hàng chục, hàng trăm lần so với tác phẩm chỉ được ghi là “attributed to”, tức “được cho là của” người đó. Danh tiếng nghệ sĩ cũng quan trọng, và danh tiếng ấy được gây dựng từ các triển lãm cá nhân, những lần góp mặt ở biennale, tác phẩm được bảo tàng mua lại và cách giới phê bình đón nhận. Kế đến là tình trạng tác phẩm và lịch sử phục chế, độ hiếm của nó trong toàn bộ sự nghiệp nghệ sĩ, và chất liệu: sơn dầu trên toan thường bán được giá hơn tác phẩm trên giấy, còn tác phẩm trên giấy lại hơn tranh in. Tác phẩm từng nằm trong bộ sưu tập của MoMA, Tate hay Louvre thì như được đóng một con dấu mà thị trường thứ cấp tin tưởng. Thời điểm cũng có ảnh hưởng: dữ liệu so sánh từ năm năm trước là đã lỗi thời.\nChuyên gia của Sotheby\u0026rsquo;s còn hay nhắc tới “giá trị cảm xúc”: nhà sưu tập khao khát đến mức nào, các màn đua giá nóng lên ra sao, tác phẩm chạm tới cảm xúc riêng của từng người đến đâu. Chính yếu tố này có thể đẩy giá búa (hammer price) vượt xa mọi mức ước tính hợp lý. Bạn không mô hình hóa trực tiếp được thứ đó, nhưng vẫn nắm được đại khái hình dạng của nó một cách gián tiếp, thông qua mức ước tính của nhà đấu giá.\nTrong thực tế, cách định giá phổ biến nhất là so sánh với các giao dịch tương đương (comparable sales): tìm kết quả đấu giá gần đây của những tác phẩm tương tự của cùng nghệ sĩ, rồi điều chỉnh theo những điểm khác nhau. Mô hình XGBoost cũng làm đúng việc ấy, chỉ khác là ở quy mô lớn hơn và với nhiều feature hơn hẳn số yếu tố mà một giám định viên có thể tự theo dõi bằng tay.\nLuận điểm chính của bài báo # Lee và cộng sự đưa ra một lập luận khá ngược đời: nội dung thị giác của tác phẩm chỉ giải thích được rất ít về giá của nó. Mô hình chỉ dùng feature thị giác của họ, gồm màu sắc, bố cục, mật độ cạnh và embedding từ ResNet18, đạt R² khoảng 0.055. Tức là chỉ nhỉnh hơn một chút so với việc lấy giá trung bình ra đoán bừa.\nTrong khi đó, một mô hình chỉ dùng các feature “xã hội” ở cấp nghệ sĩ (giai đoạn sự nghiệp, lịch sử triển lãm, giá đấu giá trước đây, thứ hạng trên ArtFacts) lại đạt R² ≈ 0.73. Thêm mức ước tính trước phiên của nhà đấu giá vào làm feature thì con số này leo lên 0.92.\nNói cách khác, bản thân tác phẩm gần như không quan trọng. Điều quan trọng là ai làm ra nó, và thị trường đã đánh giá người đó thế nào.\nLuận điểm phụ của bài báo là khoảng cách này còn lớn hơn nữa ở các thị trường mới nổi, tức là mọi nơi nằm ngoài nhóm thị trường lâu đời gồm Mỹ, Anh, Pháp và Đức. Ở những thị trường đó, tín hiệu xã hội càng có trọng lượng, còn ước tính của chuyên gia lại kém chính xác hơn, nên dự đoán bằng thuật toán có nhiều đất dụng võ hơn.\nTái hiện bài báo # Yêu cầu đặt ra khá rõ ràng: tái hiện bài báo dưới dạng một MVP do một lập trình viên làm. Không deploy lên production, không có pipeline dữ liệu live. Chỉ cần làm lại cho sát với bài gốc, mã ở mức prototype, kèm một bản báo cáo ghi lại những gì tìm được.\nDữ liệu # Nhóm tác giả đã đính kèm sẵn các tệp CSV đã làm sạch trong phần tài liệu bổ sung:\nTệp Số dòng Nội dung Df_mloutfull.csv 86,221 Toàn bộ dataset thô, hơn 500 cột df_for_ml_improved_up_to_2012.csv 34,200 Dataset chính đã làm sạch, 1996–2012 df_for_ml_improved_old_market.csv 29,853 Chỉ gồm thị trường lâu đời df_for_ml_improved_new_market.csv 4,346 Chỉ gồm thị trường mới nổi transactions.csv 114,283 Bản ghi đấu giá thô kèm link ảnh Tổng cộng khoảng 5 GB, nạp vào RAM thoải mái. Train XGBoost trên dataset cỡ này chưa tới năm phút bằng CPU của laptop, và mô hình chính cũng không cần GPU. Với dữ liệu dạng bảng ở quy mô này, gradient-boosted tree train gần như chẳng tốn gì. Chuyện này rất dễ quên nếu mấy năm qua bạn chỉ quanh quẩn với neural net.\nFeature # Mô hình dùng 38 feature, chia làm ba nhóm.\nVề nghệ sĩ (30 feature):\nNhân khẩu học: tuổi, giới tính, trình độ học vấn, có học trường top hay không. Sự nghiệp: thứ hạng ArtFacts, số triển lãm cá nhân, số triển lãm nhóm, số lần góp mặt ở biennale, giải thưởng. Bộ sưu tập: số bộ sưu tập tư nhân và công cộng đang giữ tác phẩm. Lịch sử giá: giá trung bình, trung vị, cao nhất và thấp nhất trong 5 và 10 lần bán gần nhất của nghệ sĩ. Lịch sử giá đã quy đổi theo kích thước (giá trên mỗi inch vuông). Địa lý: nơi nghệ sĩ làm việc và sinh sống, mã hóa thành các cờ theo quốc gia. Cờ khớp: thể loại của tác phẩm có đúng với thể loại quen thuộc của nghệ sĩ không? Quốc gia nơi diễn ra phiên bán có khớp không? Về thị trường (8 feature):\nHạng của nhà đấu giá (1–4). Mặt bằng giá theo quốc gia và khu vực trong năm đó (thấp nhất, trung bình, trung vị, cao nhất). Về bản thân tác phẩm (không liên quan đến hình ảnh):\nChiều rộng, chiều cao, diện tích tính bằng inch vuông. Nhóm chất liệu (tranh vẽ, tranh in, ảnh, điêu khắc, khác). Để ý thứ bị thiếu: không có bất cứ thông tin nào về nội dung hình ảnh thật sự của tác phẩm. Phần đó chỉ xuất hiện trong một thí nghiệm ablation riêng.\nChia tập train/test # Dữ liệu được chia theo thời gian chứ không chia ngẫu nhiên. Mọi giao dịch trước tháng 5 năm 2011 vào tập train (~80%, ~27,360 bản ghi), mọi giao dịch sau đó vào tập test (~20%, ~6,840 bản ghi). Nếu chia ngẫu nhiên, thông tin từ tương lai sẽ rò rỉ vào qua các feature lịch sử giá của nghệ sĩ. Khi các bản ghi của cùng một nghệ sĩ bị rải lẫn vào cả train lẫn test, những dòng test đã “nhìn thấy” giá của nhau qua các rolling window, và R² của bạn sẽ đẹp hơn thực tế.\nKiểu lỗi này nhìn ra rồi thì thấy hiển nhiên, nhưng lần đầu làm thì rất dễ bỏ sót.\nMô hình # Tôi dùng XGBoost regressor để dự đoán log10(price_usd), tìm hyperparameter trên max_depth và learning_rate, rồi đánh giá trên một phần validation để riêng. Có hai biến thể:\nKhông dùng ước tính của chuyên gia. Mục tiêu R² ≈ 0.73. Có dùng ước tính của chuyên gia. Mục tiêu R² ≈ 0.92. Biến thể “có ước tính” lấy thêm mức ước tính thấp và cao trước phiên của nhà đấu giá làm feature. Chỉ riêng hai con số ước tính này đã đạt R² ≈ 0.90. Cộng thêm các feature xã hội vào thì được thêm 0.02 nữa. Con số tuyệt đối thì nhỏ, nhưng 0.02 đó chính là phần mô hình thực sự dự đoán được vượt ra ngoài những gì chuyên gia đã biết.\nMục tiêu của tôi # Tôi không định vượt mặt bài báo. Mục tiêu chỉ là đạt R² trong khoảng ±0.03 so với kết quả của họ ở mọi điều kiện. Với các con số chính (~0.73 khi chỉ dùng metadata, ~0.92 khi có ước tính), việc tái hiện khá thuận lợi: XGBoost cho kết quả tất định khi cố định seed, còn các feature được mô tả rõ ràng. Tôi đoán trước sẽ bị lệch ở hai chỗ. Một là phần ablation feature thị giác, nơi chỉ cần khác một chút về số chiều PCA hay khâu tiền xử lý ảnh là kết quả đã xê dịch. Hai là phần thị trường mới nổi, vốn chỉ có 4,346 bản ghi nên mức nhiễu nền cao hơn.\nVì sao feature thị giác gần như vô dụng # Với tôi, đây là kết quả thú vị nhất. Pipeline thị giác của bài báo trích ra 8,971 con số cho mỗi bức ảnh:\nGIST descriptor (960 chiều): bố cục không gian tổng thể. Histogram of Oriented Gradients (2,915 chiều): cấu trúc các đường cạnh. Color histogram (4,096 chiều): phân bố bảng màu. ResNet18 features (1,000 chiều): embedding cấp cao từ một mô hình pretrained. Colorfulness (1 chiều): một con số đo độ rực màu. Complexity (1 chiều): một con số đo mật độ cạnh. Nén hết chỗ đó bằng PCA rồi đưa vào XGBoost, bạn được R² ≈ 0.055. Riêng màu sắc đạt 0.056. Cấu trúc cạnh đạt 0.029. Còn embedding của neural net chỉ được 0.009, coi như bằng không.\nCách giải thích gọn nhất là giá đấu giá được quyết định ở cấp nghệ sĩ chứ không phải ở từng tác phẩm. Hai bức tranh của cùng một nghệ sĩ sẽ bán được số tiền na ná nhau, bất kể trên toan vẽ gì, vì thứ người mua thật sự trả tiền là chữ ký.\nAi từng đọc phần ghi chú trong catalogue đấu giá sẽ hiểu vì sao lại như vậy. Gần như toàn bộ câu chữ dành cho provenance, lịch sử triển lãm và các giao dịch tương đương. Bản thân tác phẩm thì hầu như chẳng được mô tả gì.\nNếu nghiêm túc nhìn vào con số 5% đó, thì nội dung thẩm mỹ của một tác phẩm gần như chẳng dính dáng gì tới giá trị thị trường của nó. Thị trường trả tiền cho thứ gì thì trả, nhưng không phải cho những gì nằm trên toan. Một mô hình hiểu nghệ thuật đến từng pixel sẽ đoán giá kém hơn một mô hình chỉ biết mỗi CV của nghệ sĩ.\nGiai đoạn 3 chỉ làm khi đủ điều kiện # Cột link ảnh trong dữ liệu của bài báo trỏ tới các URL còn hoạt động vào năm 2012. Đến năm 2026 thì phần lớn đã chết. Trong kế hoạch, tôi đã ghi chú rủi ro này ngay từ ngày thứ 8: lấy ngẫu nhiên một trăm URL xem còn bao nhiêu cái mở được. Nếu hơn một nửa đã mất thì bỏ qua hẳn giai đoạn thị giác và ghi lại lý do.\nDù sao thì feature thị giác cũng chỉ giải thích khoảng 5% độ biến thiên của giá. Mất hai tuần vật lộn với đống link hỏng chỉ để xác nhận một con số nhỏ cỡ đó thì không đáng. Tốt hơn là ghi chuyện link chết vào báo cáo rồi làm tiếp phần khác.\nThị trường lâu đời và thị trường mới nổi # Bài báo train các mô hình riêng cho thị trường lâu đời (Mỹ, Anh, Pháp, Đức) và thị trường mới nổi (19 quốc gia còn lại). Kết quả chính như sau:\nNhóm feature R² thị trường lâu đời R² thị trường mới nổi Chỉ thị giác 0.053 0.056 Metadata (tín hiệu xã hội) 0.667 0.750 Metadata + ước tính 0.916 0.859 Có hai điểm đáng chú ý. Thứ nhất, tín hiệu xã hội ở thị trường mới nổi lại quan trọng hơn chứ không kém đi. Tôi cứ tưởng thị trường lâu đời có dữ liệu phong phú hơn thì feature xã hội sẽ phát huy tốt hơn, nhưng hóa ra ngược lại.\nThứ hai, ước tính của chuyên gia ở thị trường mới nổi kém chính xác hơn. Đó chính là khoảng cách giữa 0.916 và 0.859 sau khi thêm ước tính vào. Ở thị trường lâu đời, một dự đoán bằng thuật toán phải cạnh tranh với chuyên gia Sotheby\u0026rsquo;s, người có cả chục năm dữ liệu giao dịch tương đương chống lưng. Còn ở thị trường mới nổi, chuyên gia có ít dữ liệu hơn, nên thuật toán có nhiều chỗ để chứng tỏ giá trị hơn.\nNếu một ngày nào đó bạn muốn thương mại hóa một mô hình như thế này, thì thị trường mới nổi là nơi nó thật sự đáng đồng tiền bát gạo.\nNhững giới hạn cần nói rõ # Đây là bản tái hiện một bài báo, không phải hệ thống chạy production. Không có pipeline dữ liệu live: mô hình train trên dữ liệu đấu giá 1996–2012 và không bao giờ được cập nhật. Không có deploy: đầu ra là notebook và một bản báo cáo, chứ không phải API dự đoán. Không có cơ chế phát hiện drift, nên train xong thì mô hình hoàn toàn không biết thị trường đã thay đổi từ lúc nào. Dataset cũng bị đóng băng, nên crypto art, NFT hay những biến động thị trường sau COVID đơn giản là không có trong đó. Nếu muốn dự đoán giá ở K Auction hay Seoul Auction, bạn sẽ cần feature riêng cho từng nhà đấu giá, và có khi cả một mô hình riêng cho thị trường Hàn Quốc. Ở đây chưa có gì chuyên biệt đến mức ấy.\nMô hình cũng chỉ phản ánh các quy luật trong quá khứ. Có ý kiến cho rằng thị trường đương đại đã thay đổi về cấu trúc vào khoảng năm 2015, khi các hội chợ nghệ thuật trở thành kênh bán hàng chính của thị trường sơ cấp và một thế hệ nhà sưu tập mới bước vào cuộc chơi. Nếu đúng vậy, các quy luật của giai đoạn 1996–2012 có thể không còn áp dụng được nữa.\nTừ bài báo đến nền tảng # Đọc xong bài báo, tôi nhìn dự án thứ hai theo một cách khác hẳn. Ban đầu Art Evaluator được định hình là một sàn gọi vốn cộng đồng (crowdfunding) cho các triển lãm nghệ thuật. Nghệ sĩ gọi vốn cho các triển lãm sắp tới, còn nhà đầu tư được tiếp cận nghệ sĩ mới nổi sớm hơn so với khi chờ họ lên sàn đấu giá.\nPhần sản phẩm người dùng nhìn thấy được cố tình giữ đơn giản. Nghệ sĩ đăng các triển lãm sắp tới đang cần vốn (địa điểm, ngày tổ chức, các tác phẩm dự kiến). Nhà đầu tư rót tiền cho từng triển lãm cụ thể theo các mức ($100, $500, $1k, $5k), và được chia phần lời khi tác phẩm bán được. AI agent lo phần lớn việc trò chuyện giữa nghệ sĩ và nhà đầu tư, nên hai bên không cần cùng online mà câu chuyện vẫn tiếp tục. Điều này đặc biệt có ích khi một gallery ở Hàn Quốc làm việc với một nhà sưu tập ở Mỹ, hai bên lệch nhau tới 14 tiếng.\nMột khi đã thấm rằng tài sản thật sự là người nghệ sĩ chứ không phải tác phẩm, thì data model cũng phải đổi theo. Thứ bạn cần ghi lại là ai từng sở hữu từng tác phẩm, tác phẩm đã được trưng bày ở đâu, các tác phẩm tương đương bán được bao nhiêu, và còn ai khác đang giữ tác phẩm của cùng nghệ sĩ. Chuỗi sở hữu, sự công nhận từ các tổ chức, dữ liệu đấu giá để so sánh, mạng lưới nhà sưu tập: knowledge graph sinh ra chính là để lưu những thứ như vậy.\nBackend Django (apps/artworks/models.py) mô hình hóa bốn thành phần cơ bản:\nArtist: người làm ra tác phẩm. AuctionRecord: các giao dịch trong quá khứ, kèm giá, ngày bán và nhà đấu giá. OwnershipRecord: ai nắm giữ tác phẩm nào, từ ngày nào đến ngày nào. ScrapeLog: nguồn gốc của chính dữ liệu, để kiểm tra lại được từng bản ghi lấy từ đâu. Ứng dụng di động (Expo + React Native + @shopify/react-native-skia) hiển thị toàn bộ dữ liệu này thành một đồ thị force-directed. useGraphData gọi endpoint /api/graph/ của Django, useForceLayout chạy mô phỏng d3-force bằng JS ở mỗi tick, còn GraphCanvas vẽ kết quả bằng Skia. Có một thanh trượt thời gian để bạn kéo qua từng năm và xem mạng lưới thay đổi ra sao: nhà sưu tập nào tham gia vào lúc nào, nghệ sĩ nào được bảo tàng nào để mắt tới, tác phẩm nào đổi chủ trong đợt suy thoái năm 2008.\nMỗi loại node có hình dạng và màu sắc riêng:\nLoại node Hình dạng Màu Nghệ sĩ Hình thoi đỏ Tác phẩm Chữ nhật bo góc xanh dương Nhà sưu tập Hình tròn xanh lá Người buôn tranh Hình tròn cam Bảo tàng Hình tròn tím Di sản nghệ sĩ (estate) Hình tròn xanh mòng két Nhà đấu giá Hình lục giác xám Mấy hình dạng này không phải để trang trí. Khi zoom ra để xem vài trăm node cùng lúc, chỉ cần nhìn đường viền là biết đó là loại thực thể nào, khỏi phải chờ nhãn hiện lên. Một hình thoi có nhiều hình tròn vây quanh là một nghệ sĩ cùng mạng lưới nhà sưu tập của họ. Một hình lục giác nối với nhiều hình thoi là một nhà đấu giá đang phụ trách cả một danh sách nghệ sĩ. Chỉ cần liếc qua là đọc được cấu trúc của cả một thị trường.\nTab thứ hai hiển thị cùng dữ liệu đó trên trục thời gian vis-timeline: các giai đoạn sở hữu là những thanh ngang, còn mỗi lần bán là một điểm sự kiện. Vẫn là đồ thị ấy, chỉ nhìn qua một lăng kính khác. Cách này hữu ích khi bạn muốn xem ai nắm giữ tác phẩm nào theo trình tự thời gian, thay vì hình dạng của mạng lưới ở một thời điểm.\nBản thân Art Evaluator không phải công cụ dự đoán giá. Nó là tầng dữ liệu nằm bên dưới một công cụ như thế. Nếu muốn nuôi một mô hình kiểu của Lee và cộng sự bằng dữ liệu live, luôn được cập nhật, thay vì mấy tệp CSV đóng băng từ năm 2012, thì đây chính là cấu trúc bạn cần.\nĐịnh giá bằng đám đông, dưới dạng một trò chơi # Phần tôi chưa làm, nhưng lại là phần tôi hứng thú nhất, là biến việc định giá nghệ thuật thành một trò chơi.\nBài báo của Lee và cộng sự lấy mức ước tính trước phiên của nhà đấu giá làm feature, và nhờ nó mà R² tăng vọt. Những ước tính này có giá trị vì chúng là phán đoán tổng hợp của các chuyên gia được đào tạo bài bản, đã từng xem hàng nghìn tác phẩm tương đương, và bản thân họ cũng có quyền lợi gắn với kết quả. Nhưng chuyên gia của nhà đấu giá là nguồn lực vừa hiếm vừa đắt. Cả thế giới chỉ có vài trăm người, họ chỉ làm việc với những lô tác phẩm được ký gửi cụ thể, và họ không đụng tới phần đuôi dài (long tail): những nghệ sĩ mà chưa ai mang tác phẩm đi ký gửi.\nTôi cứ quay đi quay lại một câu hỏi: liệu có tổng hợp được một tín hiệu tương đương từ phán đoán của một nhóm người đông hơn và rẻ hơn nhiều hay không?\nThiết kế sơ bộ như sau:\nCho người dùng xem một tác phẩm: ảnh, kích thước, năm sáng tác, tên nghệ sĩ, CV tóm tắt. Hỏi họ tác phẩm đó sẽ bán được bao nhiêu ở phiên đấu giá. Cho họ chọn một khoảng giá, hoặc đoán thẳng con số giá búa. Sau khi họ chốt câu trả lời thì mới cho xem giá bán thật. Chấm độ chính xác của họ theo kiểu Brier score, tích lũy theo thời gian. Hiển thị bảng xếp hạng, chuỗi ngày chơi liên tục (streak) và huy hiệu cho những chuỗi đoán trúng liên tiếp. Đánh trọng số cho các dự đoán sau này của từng người dựa trên độ chính xác trước đó của họ. Về bản chất, đây là một thị trường dự đoán (prediction market) cho giá tranh, chỉ khoác lên lớp áo của một trò đoán giá. Vòng lặp y hệt Geoguessr hay điểm puzzle trên chess.com: chơi lại được nhiều lần, chấm điểm được, và có phản hồi để bạn luyện kỹ năng dần lên. Ai đoán giỏi sẽ leo lên bảng xếp hạng, và những lần đoán sau của họ sẽ có trọng lượng lớn hơn trong tín hiệu tổng hợp.\nCái hay nằm ở phía mô hình. Kết quả tổng hợp có trọng số của các lượt đoán trở thành một feature để đưa vào XGBoost, bên cạnh các tín hiệu xã hội. Nếu trung vị các lượt đoán của đám đông, sau khi đánh trọng số theo độ chính xác, bám sát giá búa, thì bạn đã có trong tay thứ gần giống một bản ước tính “nhân tạo” của nhà đấu giá cho những tác phẩm chưa có ước tính thật. Đó đúng là lỗ hổng ở phần đuôi dài các nghệ sĩ, chỗ mà mô hình của Lee và cộng sự cần được hỗ trợ nhất.\nCòn một khía cạnh nữa là bài toán cold-start. Mô hình học tốt với những nghệ sĩ có lịch sử giá dày dặn, nhưng học rất tệ với nghệ sĩ mới chỉ có ba lần bán. Ba lần bán cộng thêm một nghìn lượt đoán của đám đông thì ít ra cũng có cái để làm việc. Định giá bằng đám đông là một cách tạo ra tín hiệu để train cho những trường hợp mà dữ liệu gốc không bao phủ tới.\nGamification quan trọng ở đây, vì cách duy nhất để đám đông trở nên đáng tin là làm cho việc tham gia đủ vui để người ta chơi thật nhiều. Một form hỏi đúng một câu chỉ cho bạn dữ liệu nhiễu, từ những người bấm cho xong một lần rồi thôi. Còn một streak hằng ngày có bảng xếp hạng và huy hiệu cho người đoán sát thì giữ chân được cùng một người, để họ đưa ra 500 dự đoán cân nhắc kỹ lưỡng trong một năm. Số lượng lớn, cộng với việc người chơi tự sàng lọc (người đoán giỏi ở lại, người đoán dở chán rồi bỏ đi), mới là thứ cho bạn tín hiệu thay vì nhiễu.\nTrước khi xây bất cứ thứ gì, tôi còn phải trả lời vài câu hỏi thiết kế. Nên cho người dùng xem bao nhiêu thông tin: chỉ ảnh và CV, hay cả các giao dịch tương đương? Càng nhiều ngữ cảnh thì đoán càng có cơ sở, nhưng quá một ngưỡng nào đó thì người dùng chủ yếu chỉ nhắc lại những gì bạn vừa cho họ xem. Phản hồi về giá nên chi tiết đến đâu: tiết lộ chính xác giá búa, chỉ đưa một khoảng, hay chỉ báo họ có nằm trong ±20% hay không? Chi tiết quá thì người chơi quen neo vào những con số cụ thể, còn mơ hồ quá thì họ không tự điều chỉnh được. Rồi còn chuyện lách bảng xếp hạng: người chơi sẽ tìm đến những nghệ sĩ họ đã biết, mà đoán giá một bức Banksy thì chủ yếu là kiến thức đố vui chứ đâu phải kỹ năng. Có lẽ cách xử lý là bắt buộc chọn tác phẩm ngẫu nhiên, giống cách Duolingo chọn bài học, cộng thêm một bảng xếp hạng riêng cho các vòng “chơi mù” giấu tên nghệ sĩ. Cuối cùng, thiên kiến nhận thức muộn (hindsight bias) là không tránh được: một khi người dùng đã biết một bức Basquiat bán được $110M, họ không thể giả vờ là chưa biết. Thứ giữ cho vòng lặp này trung thực là nguồn tác phẩm mới, chưa ai xem, mà nguồn đó thì mỗi tuần chỉ có hạn.\nCách tiếp cận bằng gamification cũng giúp gắn kết hai nửa của nền tảng. Phía crowdfunding thưởng cho nhà đầu tư khi triển lãm làm ăn có lãi. Phía trò đoán giá thì thưởng cho bất kỳ ai đoán giá giỏi, dù có bỏ tiền đầu tư hay không. Cả hai đều xuất phát từ cùng một ý tưởng: biến phán đoán tập thể của đám đông thành một tín hiệu có thể giao dịch. Một bên giao dịch bằng vốn, bên kia giao dịch bằng sự chú ý. Người chơi nào thật sự có nghề thì về sau có thể “lên lớp” sang phía nhà đầu tư, mang theo một thành tích đoán giá kiểm chứng được. Đó là một bộ lọc tốt hơn nhiều so với kiểu “quen đúng ông chủ gallery”.\nNhững gì tôi rút ra # Ước tính trước phiên thật sự rất tốt. Nhà đấu giá hay bị chê là định giá thiếu minh bạch, nhưng chỉ riêng các ước tính của họ đã giải thích được gần 90% độ biến thiên của giá. Chuyên gia làm cách nào thì làm, họ làm tốt. Vậy mà xét về mặt thống kê, bản thân tác phẩm lại gần như không quan trọng, một sự thật khá khó chịu nếu bạn yêu nghệ thuật vì cái đẹp. Thị trường không định giá thứ bạn nhìn thấy. Nó định giá dựa trên số tiền người khác đã trả cho những tác phẩm na ná.\nVề phía mô hình hóa, có hai thói quen tỏ ra rất đáng giá. Chia dữ liệu theo thời gian là cách trung thực duy nhất để đánh giá mô hình dự đoán giá. Chia ngẫu nhiên thì cho bạn những con số R² đẹp như mơ, nhưng đưa lên production là vỡ. Và gradient-boosted tree vẫn là công cụ phù hợp cho bài toán này: XGBoost đánh bại mọi baseline dùng neural net trong bài báo, và với dữ liệu dạng bảng ở quy mô này, lại có sẵn chừng ấy feature được thiết kế công phu, thì chẳng có lý do gì phải tìm đến transformer.\nVề mặt kinh doanh, cơ hội thú vị nằm ở các thị trường mới nổi, nơi định giá của chuyên gia còn mỏng nhất và dự đoán bằng thuật toán có nhiều khoảng trống nhất để lấp. Đám đông cũng là một nguồn dữ liệu đang bị bỏ phí. Feature mạnh nhất trong mô hình của Lee và cộng sự là mức ước tính trước phiên của nhà đấu giá. Nếu một đám đông đã được hiệu chỉnh tạo ra được tín hiệu tương đương cho phần đuôi dài các nghệ sĩ, thì đó là một cơ hội thật, vừa làm đầu vào cho mô hình, vừa có thể đứng riêng thành một sản phẩm.\nRốt cuộc, hai dự án bổ trợ cho nhau nhiều hơn tôi dự tính. Bản tái hiện bài báo cho tôi biết điều gì dự đoán được giá. Phần làm nền tảng cho tôi biết cần cấu trúc dữ liệu thế nào để đem hiểu biết đó ra dùng. Ý tưởng gamification nối hai thứ lại: knowledge graph cung cấp tác phẩm và lịch sử giao dịch, trò đoán giá tạo ra một feature ước tính từ đám đông, rồi mô hình dùng feature đó cùng các tín hiệu xã hội để dự đoán giá cho những nghệ sĩ mà nếu thiếu nó thì mô hình hoàn toàn không nhìn thấy.\nChưa phần nào xong cả. Bản tái hiện bài báo mới là một prototype chạy được kèm bản báo cáo, nền tảng là một bản demo với dữ liệu mẫu, còn trò đoán giá thì mới chỉ nằm trên mấy ghi chú thiết kế bạn vừa đọc. Nhưng ý tưởng xuyên suốt đủ nhất quán để tôi tin rằng hướng đi đúng đã khá rõ: định giá nghệ sĩ thay vì tác phẩm, tổng hợp ý kiến đám đông thay vì chỉ trông vào chuyên gia, và coi giá trị của tác phẩm là thứ cả thị trường cùng tạo ra, chứ không phải thứ do chuyên gia định sẵn rồi ban xuống.\nNếu có thời gian để xây, đó sẽ là phiên bản tôi làm tiếp theo.\n","date":"01/04/2026","description":"Ghi chép sau khi tái hiện một bài báo năm 2024 dùng XGBoost để dự đoán giá tranh, và dựng một nền tảng knowledge graph xoay quanh nó. Kết luận khá bất ngờ: thị trường định giá người nghệ sĩ chứ không định giá tác phẩm.","externalUrl":null,"permalink":"/vi/posts/2026/2026-04-01-art-evaluation-algorithm/","section":"Bài viết","summary":"","title":"Xây thuật toán định giá tác phẩm nghệ thuật: rốt cuộc điều gì quyết định giá đấu giá?","type":"posts"},{"content":"Thời gian tôi dành cho việc gỡ lỗi nhiều đến mức khó tin. Mà lập trình viên nào chẳng thế. Dạo này phần lớn thời gian đó tôi ngồi trong Xcode và simulator, lần theo lỗi trong chính ứng dụng của mình, và có một điều tôi cứ thấy lạ mãi: Xcode đi kèm cả một kho công cụ debug, vậy mà gần như chẳng ai đụng tới. Dưới đây là quy trình tôi đúc kết qua nhiều năm, chỉ gồm những phần thực sự giúp tiết kiệm thời gian mỗi khi có gì đó hỏng.\nBreakpoint làm được nhiều hơn bạn nghĩ # Đa số chúng ta chỉ click vào lề trái để đặt breakpoint rồi thôi. Nhưng thử click chuột phải vào một breakpoint xem, bạn sẽ thấy cả một menu tùy chọn, và nó thay đổi hẳn cách bạn gỡ lỗi.\nConditional Breakpoint # Giả sử bạn có một vòng lặp xử lý 1000 phần tử, và chỉ riêng phần tử thứ 347 là trục trặc. Nếu phải dừng ở breakpoint 346 lần trước khi tới được nó thì coi như mất toi một buổi chiều.\nfor i in 0..\u0026lt;users.count { processUser(users[i]) // Set breakpoint here with condition: i == 347 } Click chuột phải vào breakpoint, thêm điều kiện i == 347, thế là Xcode chỉ dừng khi điều kiện đó đúng. Mấy điều kiện kiểu userId == \u0026quot;abc123\u0026quot; hay error != nil cũng dùng ngon y như vậy.\nSymbolic Breakpoint # Loại này dùng khi bạn không biết một method đang được gọi từ đâu. Mở breakpoint navigator (Cmd+8), bấm nút +, rồi chọn \u0026ldquo;Symbolic Breakpoint\u0026rdquo;.\nMuốn biết mỗi lần có view controller nào được load? Đặt symbol là viewDidLoad, Xcode sẽ dừng lại ở từng cái một. Thấy nhiều quá? Thì thu hẹp lại: MyViewController.viewDidLoad.\nCó mấy cái tôi để cố định trong mọi project:\nUIViewAlertForUnsatisfiableConstraints bắt các xung đột Auto Layout ngay khi chúng xảy ra objc_exception_throw dừng ở mọi exception Objective-C, trước khi ứng dụng kịp crash malloc_error_break giúp tìm ra các lỗi cấp phát bộ nhớ Exception Breakpoint # Đây nên là thứ đầu tiên bạn thiết lập khi tạo project mới. Thêm một cái (breakpoint navigator, + → Exception Breakpoint) là Xcode sẽ dừng ngay lúc có exception được throw, chứ không phải đợi đến khi ứng dụng đã chết hẳn.\nNhờ nó mà tôi bắt được không biết bao nhiêu lỗi unwrap nil, nhiều hơn mức tôi muốn thừa nhận. Không có nó thì mấy lỗi này chỉ hiện ra dưới dạng những vụ crash khó hiểu trong console.\nBreakpoint Action # Breakpoint không nhất thiết phải dừng chương trình. Sửa một breakpoint, bấm \u0026ldquo;Add Action\u0026rdquo;, và bạn có thể cho nó:\nIn giá trị biến ra mà không dừng: \u0026quot;User count: @(users.count)@\u0026quot; Tự động chạy lệnh LLDB Chạy shell script Phát âm thanh (tôi hay dùng cho những nhánh mã hiếm khi chạy tới, để biết ngay khi nó xảy ra) Tick thêm \u0026ldquo;Automatically continue after evaluating actions\u0026rdquo; là breakpoint biến thành một công cụ ghi log, không bao giờ làm gián đoạn bạn.\nLLDB: những lệnh console bạn sẽ dùng thật # Khi chương trình dừng ở breakpoint, cái console ở dưới cùng không chỉ để đọc crash log. Đó chính là LLDB, và nó hữu ích hơn bạn tưởng nhiều.\nCơ bản # (lldb) po user ▿ User - id: \u0026#34;123\u0026#34; - name: \u0026#34;John Doe\u0026#34; - email: \u0026#34;john@example.com\u0026#34; po in object ra ở dạng dễ đọc. Riêng lệnh này đã chiếm tới 90% số lần tôi dùng LLDB.\nCần chi tiết hơn thì dùng p:\n(lldb) p user.name (String) $R0 = \u0026#34;John Doe\u0026#34; Còn muốn xem toàn bộ biến local cùng lúc:\n(lldb) frame variable Thay đổi giá trị lúc runtime # LLDB thực sự đáng đồng tiền ở chỗ này: bạn sửa được giá trị biến mà không cần compile lại.\n(lldb) expr user.name = \u0026#34;Jane Doe\u0026#34; (lldb) expr index = 0 Tìm ra lỗi rồi và muốn thử cách sửa mà không phải build lại? Cứ đổi giá trị biến rồi cho chương trình chạy tiếp. Tôi dùng cách này liên tục mỗi khi cần khoanh vùng các edge case.\nGọi method cũng được luôn:\n(lldb) po self.refreshUI() (lldb) expr navigationController?.popViewController(animated: true) Di chuyển trong chương trình # (lldb) bt # Show the call stack (lldb) frame select 3 # Jump to a different stack frame (lldb) continue # Keep running (or just \u0026#39;c\u0026#39;) (lldb) n # Step over (next line) (lldb) s # Step into function Watchpoint # Muốn biết khi nào một biến bị thay đổi? Đặt watchpoint:\n(lldb) watchpoint set variable user.isLoggedIn Từ giờ, cứ mỗi lần giá trị đó thay đổi là chương trình dừng lại, bất kể dòng mã nào gây ra. Khi phải truy lùng những lần state bị thay đổi ngoài ý muốn thì công cụ này quý như vàng.\nConsole.app: công cụ gỡ lỗi ít người để ý # Console của Xcode dùng tạm thì ổn, cho đến khi bạn cần xem mọi thứ: system log, crash report, đủ cả. Những lúc đó tôi mở Console.app.\nNếu bạn đang dùng OSLog (và bạn nên dùng), Console.app cho phép tìm kiếm và lọc các log đó:\nimport os.log let logger = Logger(subsystem: \u0026#34;com.example.app\u0026#34;, category: \u0026#34;networking\u0026#34;) logger.info(\u0026#34;Starting API request\u0026#34;) logger.debug(\u0026#34;Request URL: \\(url.absoluteString)\u0026#34;) logger.error(\u0026#34;Failed to decode: \\(error.localizedDescription)\u0026#34;) Sau đó, trong Console.app:\nChọn simulator hoặc thiết bị đang kết nối Lọc theo subsystem của bạn: subsystem:com.example.app Lọc theo level: subsystem:com.example.app AND level:error Với những kiểu gỡ lỗi hay làm, bạn có thể lưu sẵn filter predicate. Tôi có một cái cho network request, một cái cho các thao tác với cơ sở dữ liệu, và một cái chỉ hiện error với warning.\nSao lại dùng OSLog thay vì print()? # So với việc rải print() khắp nơi thì OSLog hơn hẳn. Log có cấu trúc nên lọc được theo level và category. Nó đủ nhanh để việc ghi log không làm ứng dụng chậm đi. Trên production, dữ liệu nhạy cảm được tự động ẩn đi, và log vẫn còn nguyên kể cả khi ứng dụng crash.\nprint() vẫn ổn cho mấy lần kiểm tra nhanh rồi xóa. Còn thứ gì sau này bạn có thể cần xem lại thì nên ghi bằng OSLog.\nLọc nâng cao trong Console.app # Console.app hỗ trợ truy vấn predicate đầy đủ. Đây là mấy câu tôi dùng thường xuyên:\n# All errors and faults in your app subsystem:com.example.app AND (level:error OR level:fault) # Network requests that failed subsystem:com.example.app AND category:networking AND eventMessage CONTAINS \u0026#34;failed\u0026#34; # Everything from the last 5 minutes subsystem:com.example.app AND timestamp \u0026gt;= now(-5m) Bạn cũng có thể export log ra để đính kèm vào bug report, tiện hơn nhiều so với copy-paste từ console của Xcode.\nGỡ lỗi giao diện với View Debugger # Khi giao diện bị vỡ mà bạn không hiểu tại sao, view debugger là cách nhanh nhất để tìm ra nguyên nhân.\nChạy ứng dụng, mở tới màn hình bị lỗi, rồi bấm nút \u0026ldquo;Debug View Hierarchy\u0026rdquo; trên thanh debug của Xcode (hoặc nhấn Cmd+Shift+D). Xcode sẽ hiện toàn bộ view hierarchy dưới dạng 3D, tách ra từng lớp.\nXoay qua xoay lại một chút là vấn đề lộ ra ngay:\nView bị render nằm phía sau view khác View nằm tít ngoài màn hình View có kích thước bằng 0 Toàn bộ chuỗi constraint của view bạn đang chọn Tôi từng nhờ nó mà tìm ra những cái nút vô hình nuốt mất sự kiện tap, những label bị render ở kích thước 0x0, và có lần là một tấm ảnh lỡ tay rộng tới 10,000 pixel.\nGỡ lỗi Auto Layout # Trong view hierarchy, biểu tượng cảnh báo màu tím nghĩa là đang có xung đột constraint. Click vào đó, Xcode sẽ chỉ rõ những constraint nào đang \u0026ldquo;đánh nhau\u0026rdquo;.\nHãy đặt identifier cho constraint:\nheightConstraint.identifier = \u0026#34;ProfileImageHeight\u0026#34; Khi constraint bị vỡ, thông báo lỗi sẽ hiện \u0026quot;ProfileImageHeight\u0026quot; thay vì một địa chỉ bộ nhớ, và lúc đó log mới thực sự đọc được.\nBạn cũng có thể in cây constraint ra từ LLDB:\n(lldb) po view.hasAmbiguousLayout (lldb) po view._autolayoutTrace() Những tính năng của simulator giúp tiết kiệm thời gian # Slow Animations # Debug → Slow Animations làm mọi thứ chạy chậm lại còn 1/10. Rất hợp để xem trong một transition thực sự đang xảy ra chuyện gì, hoặc tìm hiểu vì sao một animation trông sai sai.\nGiả lập cảnh báo bộ nhớ # Debug → Simulate Memory Warning giúp bạn kiểm tra xem ứng dụng xoay xở ra sao khi thiếu bộ nhớ. Tôi đã tìm ra khá nhiều lỗi liên quan đến cache ảnh theo cách này: mọi thứ vẫn chạy ổn, cho tới khi cảnh báo được bắn ra, và tự dưng toàn bộ ảnh biến mất sạch.\nNetwork Link Conditioner # Xcode → Open Developer Tools → Network Link Conditioner\nCông cụ này giả lập mạng 3G, LTE, mạng mất gói nhiều, hoặc mất kết nối hoàn toàn. Nếu bạn từng gặp một API request chạy ngon lành trên WiFi nhưng lại timeout khi dùng 4G, thì đây là cách để tái hiện lỗi đó.\nTôi luôn để sẵn một profile tên \u0026ldquo;Bad Network\u0026rdquo;, với độ trễ 500ms và tỉ lệ mất gói 10%. Nhờ nó mà lộ ra những lỗi timeout và lỗi ở trạng thái loading, loại lỗi bạn sẽ không bao giờ thấy khi ngồi dùng WiFi tốc độ cao ở văn phòng.\nGhi đè status bar # Click chuột phải vào status bar trong simulator là bạn ghi đè được giờ, mức pin, cường độ sóng và tên nhà mạng. Rất tiện khi cần chụp screenshot đồng nhất, hoặc muốn kiểm tra giao diện trông thế nào ở các mức pin khác nhau.\nGiả lập vị trí # Debug → Location cho phép giả lập các vị trí khác nhau mà không cần rời khỏi bàn làm việc: tọa độ tùy chọn, đi bộ trong thành phố, hay lái xe trên cao tốc. Rất hữu ích khi làm các tính năng dựa trên vị trí, hoặc khi gặp những lỗi chỉ xuất hiện ở một số khu vực nhất định.\nGỡ lỗi bộ nhớ # Debug Memory Graph # Bấm nút Debug Memory Graph (Cmd+Shift+M), Xcode sẽ hiện ra mọi object đang nằm trong bộ nhớ, quan hệ giữa chúng, và phần bạn thực sự quan tâm: object nào đang bị leak.\nDấu chấm than màu tím nghĩa là có leak. Click vào là bạn thấy ngay retain cycle.\nĐây là kiểu leak tôi gặp nhiều nhất:\nclass ViewController: UIViewController { var onComplete: (() -\u0026gt; Void)? func setupHandler() { onComplete = { self.dismiss(animated: true) // ❌ Captures self strongly } } } Cách sửa là dùng weak self:\nonComplete = { [weak self] in self?.dismiss(animated: true) // ✓ No retain cycle } Delegate cũng phải khai báo weak:\nweak var delegate: ManagerDelegate? // Not just \u0026#39;var\u0026#39; Instruments # Khi cần điều tra bộ nhớ một cách nghiêm túc, hãy dùng Instruments (Product → Profile, hoặc Cmd+I).\nInstrument Allocations cho bạn thấy từng lần cấp phát object, bộ nhớ tăng lên theo thời gian ra sao, class nào đang ngốn nhiều bộ nhớ nhất, kèm stack trace cho biết mỗi lần cấp phát xảy ra ở đâu.\nTôi thường tìm ba thứ: bộ nhớ tăng đều theo thời gian (nhiều khả năng là leak), những lần cấp phát lớn bất thường (mình vừa load cả 1000 tấm ảnh một lúc à?), và những object lẽ ra đã phải được giải phóng nhưng vẫn còn đó.\nBấm đánh dấu generation (nút hình lá cờ nhỏ) trước và sau một thao tác để xem thứ gì vẫn còn nằm lại trong bộ nhớ khi đáng lẽ phải được giải phóng.\nĐo hiệu năng # Time Profiler # Product → Profile → Time Profiler. Bấm ghi, thực hiện đúng thao tác đang chậm trong ứng dụng, rồi đọc call tree.\nSắp xếp theo \u0026ldquo;Self Weight\u0026rdquo; để tìm bottleneck. Hàm nào có self weight tới 40% thì đó chính là chỗ thời gian của bạn đang bị ngốn.\nCó lần tôi phát hiện một hàm parse JSON bị gọi tới 1000 lần mỗi giây. Chuyển nó sang background queue xong là giao diện hết giật ngay.\nMain Thread Checker # Xcode tự động phát hiện những chỗ cập nhật giao diện từ background thread. Nếu bạn thấy dòng này:\nMain Thread Checker: UI API called on a background thread: -[UILabel setText:] thì nghĩa là bạn đã viết kiểu như thế này:\nURLSession.shared.dataTask(with: url) { data, response, error in self.label.text = \u0026#34;Loaded\u0026#34; // ❌ Crash! } Sửa lại như sau:\nURLSession.shared.dataTask(with: url) { data, response, error in DispatchQueue.main.async { self.label.text = \u0026#34;Loaded\u0026#34; // ✓ } } Runtime Diagnostics # Vào Edit Scheme → Run → Diagnostics. Ở đây có một loạt checkbox, mỗi cái bắt được những lỗi mà tự tay bạn sẽ không bao giờ tìm ra.\nAddress Sanitizer # Công cụ này tìm lỗi hỏng bộ nhớ: use-after-free, buffer overflow, memory leak. Nó làm ứng dụng chậm đi 2-3 lần, nhưng bù lại bắt được những lỗi gần như không thể lần ra bằng cách nào khác. Tôi bật nó mỗi khi đi săn những vụ crash lúc có lúc không.\nThread Sanitizer # Thread Sanitizer bắt data race và các lỗi liên quan đến thread. Bạn chỉ cần bật lên, chạy ứng dụng và dùng như bình thường; nếu trong mã có race condition, nó sẽ tìm ra.\nKhông thể chạy Address Sanitizer và Thread Sanitizer cùng lúc, nên khi gặp một vụ crash có biểu hiện kỳ quặc, tôi bật luân phiên từng cái một.\nZombie Objects # Tùy chọn này bắt các message được gửi tới object đã bị giải phóng. Khi bật lên, Xcode sẽ báo chính xác lúc nào bạn đang đụng vào vùng nhớ đã được giải phóng:\n*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000 Đừng để nó bật thường trực. Zombie Objects ngăn object bị giải phóng, nên bộ nhớ cứ thế tăng mãi. Nhưng để khoanh vùng một lỗi use-after-free cụ thể thì nó rất tuyệt.\nNhững tình huống hay gặp # Ứng dụng crash ngay khi mở # Trước hết hãy thêm exception breakpoint; thường thì thế là bắt được luôn.\nNếu vẫn chưa ra, mấy \u0026ldquo;nghi phạm quen mặt\u0026rdquo; là: thiếu key trong Info.plist, force-unwrap optional trong phần mã khởi tạo, hoặc dependency injection bị lỗi.\nGiao diện không cập nhật # Lần. Nào. Cũng. Vậy. Nguyên nhân chỉ nằm trong mấy trường hợp sau:\nViệc cập nhật không chạy trên main thread Outlet chưa được nối View thực ra không hiển thị (kiểm tra bằng po view.window trong LLDB; nếu kết quả là nil thì view không nằm trong hierarchy) Còn nếu là table view hay collection view: bạn quên gọi reloadData() rồi.\nỨng dụng crash vì cảnh báo bộ nhớ # Dùng Allocations trong Instruments để xem thứ gì đang giữ bộ nhớ. Thường là ảnh nằm trong cache mà không bao giờ được nhả ra, view controller không chịu giải phóng (retain cycle), hoặc một array hay dictionary nào đó cứ phình to mãi.\nHãy giả lập cảnh báo bộ nhớ trong simulator để kiểm tra phần mã dọn dẹp của bạn.\nNhững vụ crash khó hiểu # Bật sanitizer lên. Crash lúc có lúc không thường là do thread, nên hãy chạy với Thread Sanitizer. Còn crash bên trong framework của hệ thống thì thường là do hỏng bộ nhớ, nên chạy với Address Sanitizer.\nGỡ lỗi OTA update trong simulator # Ứng dụng của tôi có phát hành bản cập nhật over-the-air (OTA), và simulator là nơi dễ nhất để gỡ lỗi toàn bộ luồng cập nhật đó.\nViệc đầu tiên tôi làm là theo dõi network traffic. Mở Console.app, lọc theo subsystem của ứng dụng, bạn sẽ thấy các request và response của manifest theo thời gian thực: header nào được gửi đi, máy chủ trả về cái gì, rõ ràng từng thứ.\nTiếp theo, tôi cố tình làm cho mạng tệ đi. Network Link Conditioner cho bạn thấy luồng cập nhật xoay xở ra sao khi kết nối chậm và mất gói. Ứng dụng có bị treo không? Có hiện loading indicator không? Có chuyển sang phương án dự phòng một cách êm ái không?\nQuanh bước kiểm tra cập nhật, tôi ghi log mọi thứ: lúc bắt đầu kiểm tra, lúc phát hiện có bản mới, lúc bắt đầu tải xuống, và lúc bundle mới được load.\nimport os.log let logger = Logger(subsystem: \u0026#34;com.example.app\u0026#34;, category: \u0026#34;updates\u0026#34;) logger.info(\u0026#34;Checking for updates...\u0026#34;) let update = try await Updates.checkForUpdateAsync() logger.info(\u0026#34;Update available: \\(update.isAvailable)\u0026#34;) Cuối cùng, bạn nên kiểm tra riêng endpoint manifest. Dùng curl gọi nó với đúng những header mà ứng dụng gửi đi:\ncurl -H \u0026#34;expo-protocol-version: 1\u0026#34; \\ -H \u0026#34;expo-platform: ios\u0026#34; \\ -H \u0026#34;expo-runtime-version: 1.0.0\u0026#34; \\ https://your-server.com/api/expo-updates/manifest/ Làm vậy để chắc chắn máy chủ đang trả về đúng dữ liệu, trước khi bạn lao vào đào bới phía client.\nCông cụ bên thứ ba # Charles Proxy / Proxyman # Hai công cụ này cho bạn xem toàn bộ network traffic, sửa request và response, và thử các tình huống lỗi. Gỡ lỗi API mà thiếu chúng thì rất khổ.\nProxyman có giao diện đẹp hơn Charles và dùng trên macOS thấy tự nhiên hơn. Tôi chuyển sang từ năm ngoái và chưa một lần muốn quay lại.\nReveal # Reveal giống view debugger của Xcode nhưng mạnh hơn. Nó chạy được trên máy thật mà không cần kết nối với Xcode, nên rất hợp để truy lỗi layout ngay trên máy của tester.\nGỡ lỗi trên máy thật # Debug không dây # Cắm máy vào Mac qua cáp USB một lần, vào Window → Devices and Simulators, tick \u0026ldquo;Connect via network\u0026rdquo;, rồi rút cáp ra. Chỉ cần máy và Mac dùng chung mạng WiFi là máy vẫn hiện trong Xcode.\nDevice Console # Window → Devices and Simulators → Open Console. Ở đây hiện đủ mọi thứ: system log, log của ứng dụng, crash report. Chi tiết hơn console của Xcode rất nhiều.\nMỗi khi tester báo \u0026ldquo;ứng dụng bị crash\u0026rdquo;, tôi luôn nhờ họ cắm máy vào để lấy console log. Rất hay gặp trường hợp có sẵn một lỗi ở cấp hệ thống nằm ngay đó, giải thích được toàn bộ câu chuyện.\nTôi thực sự làm gì khi gỡ lỗi # Thêm exception breakpoint nếu chưa có Tái hiện lỗi Đặt breakpoint gần chỗ tôi nghi là đang sai Kiểm tra giá trị bằng po trong LLDB Sửa giá trị biến để thử cách sửa mà không cần compile lại Xem view hierarchy nếu lỗi liên quan đến giao diện Profile bằng Instruments nếu lỗi liên quan đến hiệu năng Bật sanitizer nếu lỗi liên quan đến bộ nhớ hoặc thread Bí quyết là đi ngược từ triệu chứng. Crash? Exception breakpoint. Chậm? Time Profiler. Bộ nhớ? Instruments. Giao diện kỳ cục? View debugger.\nNhững mẹo tôi ước mình biết sớm hơn # Hãy dùng assertion. Chúng bắt được lỗi logic trước khi lỗi đó kịp biến thành bug:\nassert(users.count \u0026gt; 0, \u0026#34;Users array should never be empty here\u0026#34;) Commit trước khi bắt đầu gỡ lỗi. Một khi đã sửa lung tung để thử các giả thuyết, bạn sẽ cần một điểm sạch sẽ để quay về.\nGỡ lỗi xong thì xóa mấy câu print đi, hoặc ít nhất bọc chúng trong #if DEBUG. Log debug cũ để lại chỉ làm cho lỗi tiếp theo khó thấy hơn.\nHọc LLDB cho tử tế. Nhanh hơn nhiều so với việc build lại ứng dụng hai mươi lần.\nVà nhớ rằng simulator không phải điện thoại thật. Lỗi chỉ xuất hiện trên máy thật thì gần như lúc nào cũng là do thread hoặc bộ nhớ. Hoặc do hiệu năng, vì simulator chạy bằng CPU của máy Mac chứ không phải chip iPhone thật.\nNhững công cụ tôi luôn để mở # Xcode, tất nhiên rồi Console.app, lọc sẵn theo subsystem của ứng dụng Proxyman để gỡ lỗi network Instruments, khi mọi chuyện bắt đầu nghiêm trọng Không có công cụ nào lo được hết mọi thứ. View debugger không tìm ra memory leak, Instruments cũng không gỡ được xung đột Auto Layout, nên phần lớn kỹ năng nằm ở chỗ chọn đúng công cụ cho đúng triệu chứng. Cái này thì chỉ có làm nhiều mới quen: dính retain cycle đủ nhiều lần rồi, chỉ cần liếc stack trace là bạn đã \u0026ldquo;ngửi\u0026rdquo; ra nó. Gỡ lỗi chẳng bao giờ thực sự vui, nhưng ít ra nó sẽ không còn là cực hình nữa.\n","date":"26/02/2026","description":"Ghi chép về cách tôi thực sự gỡ lỗi ứng dụng iOS hằng ngày: từ conditional breakpoint, LLDB cho đến Instruments, các sanitizer và những tính năng của simulator mà tôi dùng thường xuyên.","externalUrl":null,"permalink":"/vi/posts/2026/2026-02-26-debugging-ios-mobile-apps/","section":"Bài viết","summary":"","title":"Cách tôi gỡ lỗi ứng dụng iOS với Xcode và Simulator","type":"posts"},{"content":"Với Curtain Estimator, ứng dụng di động của tôi, các bản sửa lỗi được đẩy thẳng qua mạng (over the air): phần mã JavaScript mới về tận điện thoại người dùng mà không phải chờ App Store duyệt. Thật ra EAS Update, dịch vụ hosted của Expo, sẵn sàng lo việc này giúp bạn. Nhưng tôi vẫn tự làm một bản riêng bằng Django REST Framework và Tigris S3, và từ đó tới giờ, mọi bản update cho cả iOS lẫn Android đều đi qua hệ thống này.\nTrong bài này, tôi sẽ đi qua toàn bộ hệ thống: các model, endpoint manifest, pipeline publish, và cả những cái bẫy mà phải tự vận hành thì bạn mới gặp.\nVì sao lại tự host? # Với tôi thì lý do chính là tiền. EAS Update tính phí theo mức sử dụng, nên khi bạn đẩy update thường xuyên cho một lượng người dùng ngày càng đông, chi phí sẽ cộng dồn lên. Trong khi đó, dịch vụ lưu trữ tương thích S3 như Tigris thì gần như miễn phí. Cũng có vài lý do khác, biết đâu còn quan trọng hơn với bạn. Một số ngành bắt buộc mọi asset của ứng dụng phải nằm trong hạ tầng của chính công ty. Tự nắm server cũng có nghĩa là tự nắm luồng update: nếu sản phẩm cần, bạn có thể rollout cho từng nhóm người dùng, hay A/B test các bundle khác nhau. Và pipeline update của bạn không còn phụ thuộc vào chuyện dịch vụ của Expo có ổn định hay không, hay lần tới họ sẽ đổi bảng giá ra sao.\nCác thành phần kết nối với nhau thế nào # Hệ thống gồm bốn thành phần: một backend Django trả về manifest update và lưu metadata; Tigris chứa các tệp bundle và asset thật; một script publish lo phần export, tải lên và đăng ký update; và cuối cùng là chính ứng dụng di động, được cấu hình trỏ về server của tôi thay vì server của Expo.\n┌─────────────────┐ │ Mobile App │ │ (expo-updates) │ └────────┬────────┘ │ 1. Request manifest │ (with headers: platform, runtime-version) ↓ ┌─────────────────┐ │ Django Server │ │ /api/expo- │◄─── 2. Query DB for latest update │ updates/ │ │ manifest/ │ └────────┬────────┘ │ 3. Generate presigned URLs │ ↓ ┌─────────────────┐ │ Tigris S3 │ │ (Asset Files) │◄─── 4. App downloads bundles directly └─────────────────┘ Bắt tay vào làm # Hai model # Mọi thứ đều xoay quanh hai model ExpoUpdate và ExpoUpdateAsset. Model thứ nhất lưu metadata của từng bản update:\nclass ExpoUpdate(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4) runtime_version = models.CharField(max_length=50, db_index=True) platform = models.CharField( max_length=10, choices=[(\u0026#34;ios\u0026#34;, \u0026#34;iOS\u0026#34;), (\u0026#34;android\u0026#34;, \u0026#34;Android\u0026#34;)], db_index=True ) is_active = models.BooleanField(default=True, db_index=True) manifest_data = models.JSONField() description = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ models.Index(fields=[\u0026#34;runtime_version\u0026#34;, \u0026#34;platform\u0026#34;, \u0026#34;is_active\u0026#34;, \u0026#34;-created_at\u0026#34;]) ] Trong model này có vài chỗ tôi cố ý thiết kế như vậy. runtime_version tương ứng với runtimeVersion trong app.json, và trường này gánh khá nhiều việc: client chỉ tải những update có cùng runtime version với mình. iOS và Android được lưu thành hai row riêng, vì bundle của hai nền tảng khác nhau. is_active chính là cơ chế rollback: chỉ cần tắt bản update bị lỗi là client quay về bản trước đó. Còn manifest_data lưu nguyên manifest theo giao thức Expo Updates v1 dưới dạng JSON, nên về sau muốn trả manifest ra thì chỉ cần tra một lần là xong.\nExpoUpdateAsset thì theo dõi từng tệp riêng lẻ:\nclass ExpoUpdateAsset(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4) update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE, related_name=\u0026#34;assets\u0026#34;) hash = models.CharField(max_length=255, db_index=True) key = models.CharField(max_length=255) content_type = models.CharField(max_length=100) file_extension = models.CharField(max_length=10) file_path = models.CharField(max_length=500) file_size = models.IntegerField(default=0) Asset được định danh bằng hash SHA-256, nên nội dung của chúng không bao giờ đổi và có thể cache thoải mái. Một asset dùng chung cho bao nhiêu bản update cũng được.\nEndpoint manifest # /api/expo-updates/manifest/ là nơi ứng dụng và server thật sự trao đổi với nhau. Endpoint này implement giao thức Expo Updates v1:\n@action(detail=False, methods=[\u0026#34;get\u0026#34;], url_path=\u0026#34;manifest\u0026#34;) def manifest(self, request): # Extract required headers protocol_version = request.META.get(\u0026#34;HTTP_EXPO_PROTOCOL_VERSION\u0026#34;) platform = request.META.get(\u0026#34;HTTP_EXPO_PLATFORM\u0026#34;) runtime_version = request.META.get(\u0026#34;HTTP_EXPO_RUNTIME_VERSION\u0026#34;) # Validate protocol version if protocol_version != \u0026#34;1\u0026#34;: return Response( {\u0026#34;error\u0026#34;: f\u0026#34;Unsupported protocol version: {protocol_version}\u0026#34;}, status=400 ) # Find latest active update for this runtime + platform update = ExpoUpdate.objects.filter( runtime_version=runtime_version, platform=platform, is_active=True, ).order_by(\u0026#34;-created_at\u0026#34;).first() # No update available - client uses embedded bundle if not update: response = Response(status=204) response[\u0026#34;expo-protocol-version\u0026#34;] = \u0026#34;1\u0026#34; return response # Generate presigned URLs for all assets manifest_data = self._generate_manifest_with_presigned_urls(update) return Response(manifest_data, status=200) Ở đây có ba chi tiết cần để ý. Thứ nhất, response 204 nghĩa là “không có update nào”, và ứng dụng cứ thế chạy tiếp với bundle nhúng sẵn. Thứ hai, URL của asset trong manifest là presigned URL, nên ứng dụng tải thẳng từ CDN của Tigris chứ không phải stream mọi thứ qua Django. Thứ ba, header expo-protocol-version trong response là bắt buộc, vì client sẽ kiểm tra nó.\nPublish update # Phần publish là một management command của Django, bên ngoài bọc thêm một shell script. Command publish_expo_update.py đọc đầu ra của expo export, tính hash SHA-256 cho từng asset, tải song song bundle và asset lên Tigris, ghi record vào cơ sở dữ liệu, và nếu cần thì đặt thêm một tệp JSON import vào bucket để sync lên production. Luồng chính trông như sau:\ndef _publish_platform(self, platform, runtime_version, export_dir, ...): # 1. Find the bundle file bundle_files = list(bundle_dir.glob(\u0026#34;entry-*.hbc\u0026#34;)) bundle_file = bundle_files[0] # 2. Calculate hash with open(bundle_file, \u0026#34;rb\u0026#34;) as f: bundle_content = f.read() bundle_hash = self._calculate_hash(bundle_content) # 3. Collect all assets and their hashes for asset_file in assets_dir.rglob(\u0026#34;*\u0026#34;): # Calculate hash, determine content type... assets_metadata.append({...}) # 4. Upload to S3 in parallel with ThreadPoolExecutor(max_workers=10) as executor: futures = {executor.submit(upload_asset, a): a for a in assets_metadata} # 5. Create database records with transaction.atomic(): # Deactivate previous updates ExpoUpdate.objects.filter( runtime_version=runtime_version, platform=platform, is_active=True ).update(is_active=False) # Create new update update = ExpoUpdate.objects.create(...) Còn thứ tôi thật sự gõ hằng ngày là script bọc ngoài, publish-ota-update.sh:\n# Publish to local environment ./scripts/publish-ota-update.sh ios # Publish to production ./scripts/publish-ota-update.sh ios --production # Dry run to validate ./scripts/publish-ota-update.sh --dry-run Script này load các biến môi trường production trước khi export, chạy được cho một hoặc cả hai nền tảng, và lo luôn phần sync lên production mà tôi sẽ nói ngay bên dưới. Bản đầy đủ của script nằm ở cuối bài.\nSync lên production # Tôi không muốn để sẵn credential production trên laptop, nên việc deploy lên production được chia làm hai bước. Đầu tiên là publish ở local: asset được đẩy lên Tigris, kèm một bản snapshot metadata dạng JSON. Sau đó gọi một endpoint API trên production, kèm theo đường dẫn S3, để server tự import metadata về:\n@action(detail=False, methods=[\u0026#34;post\u0026#34;], url_path=\u0026#34;import-update\u0026#34;) def import_update(self, request): # Authenticate via Bearer token secret = settings.OTA_IMPORT_SECRET token = request.META.get(\u0026#34;HTTP_AUTHORIZATION\u0026#34;, \u0026#34;\u0026#34;)[7:] # Strip \u0026#34;Bearer \u0026#34; if not hmac.compare_digest(token, secret): return Response({\u0026#34;error\u0026#34;: \u0026#34;Invalid token\u0026#34;}, status=401) # Download import JSON from Tigris s3_key = request.data.get(\u0026#34;s3_key\u0026#34;) obj = s3_client.get_object(Bucket=bucket_name, Key=s3_key) data = json.loads(obj[\u0026#34;Body\u0026#34;].read()) # Import to production database with transaction.atomic(): ExpoUpdate.objects.update_or_create(id=data[\u0026#34;id\u0026#34;], defaults={...}) for asset_data in data[\u0026#34;assets\u0026#34;]: ExpoUpdateAsset.objects.update_or_create(...) # Clean up the import JSON s3_client.delete_object(Bucket=bucket_name, Key=s3_key) Trỏ ứng dụng về server của bạn # Trong app.json, bạn đặt URL update và runtime version:\n{ \u0026#34;expo\u0026#34;: { \u0026#34;runtimeVersion\u0026#34;: \u0026#34;1.0.0\u0026#34;, \u0026#34;updates\u0026#34;: { \u0026#34;url\u0026#34;: \u0026#34;https://your-server.com/api/expo-updates/manifest/\u0026#34; } } } Chỗ này phải thật chặt chẽ: runtimeVersion ở ứng dụng và ở server lúc nào cũng phải khớp nhau. Mỗi khi thay đổi native code hoặc nâng cấp Expo SDK, hãy tăng runtime version rồi publish update mới cho version đó.\nLưu trữ trên Tigris # Tigris là dịch vụ object storage tương thích S3, rẻ hơn AWS S3 khá nhiều, lại có sẵn edge cache trên toàn cầu. Điểm này rất đáng giá khi người dùng phải tải về những bundle nặng vài MB. Cấu hình phía Django như sau:\n# settings.py BUCKET_NAME = os.getenv(\u0026#34;BUCKET_NAME\u0026#34;) AWS_ENDPOINT_URL_S3 = os.getenv(\u0026#34;AWS_ENDPOINT_URL_S3\u0026#34;) AWS_ACCESS_KEY_ID = os.getenv(\u0026#34;AWS_ACCESS_KEY_ID\u0026#34;) AWS_SECRET_ACCESS_KEY = os.getenv(\u0026#34;AWS_SECRET_ACCESS_KEY\u0026#34;) AWS_REGION = os.getenv(\u0026#34;AWS_REGION\u0026#34;, \u0026#34;auto\u0026#34;) Còn tạo client thì chỉ là boto3 như bình thường:\nimport boto3 def create_s3_client(endpoint_url, region, access_key, secret_key): return boto3.client( \u0026#34;s3\u0026#34;, endpoint_url=endpoint_url, region_name=region, aws_access_key_id=access_key, aws_secret_access_key=secret_key, ) Mọi lượt tải đều đi qua presigned URL, nên ứng dụng lấy tệp thẳng từ CDN:\npresigned_url = s3_client.generate_presigned_url( \u0026#34;get_object\u0026#34;, Params={\u0026#34;Bucket\u0026#34;: bucket_name, \u0026#34;Key\u0026#34;: asset.file_path}, ExpiresIn=3600, # 1 hour ) Bảo mật # Endpoint manifest được cố ý để không cần xác thực, vì ứng dụng phải nhận được update ngay cả khi chưa có ai đăng nhập. Endpoint import thì khác hẳn. Endpoint này ghi được vào cơ sở dữ liệu production, nên bắt buộc phải có shared secret, và secret phải được so sánh theo kiểu constant-time để chặn timing attack:\nOTA_IMPORT_SECRET = os.getenv(\u0026#34;OTA_IMPORT_SECRET\u0026#34;) # Constant-time comparison prevents timing attacks if not hmac.compare_digest(token, secret): return Response({\u0026#34;error\u0026#34;: \u0026#34;Invalid token\u0026#34;}, status=401) Tính toàn vẹn của asset thì thiết kế đã lo sẵn: tệp nào cũng được đối chiếu với hash SHA-256 của nó, nên asset nào bị chỉnh sửa sẽ bị loại ngay. Ngoài ra, presigned URL hết hạn sau một giờ, nên không ai hotlink bundle của bạn mãi được.\nGiữ cho hệ thống chạy nhanh # Nhờ composite index trên (runtime_version, platform, is_active, -created_at), query lấy manifest vẫn nhanh dù update có tích lại nhiều đến đâu:\nclass Meta: indexes = [ models.Index(fields=[\u0026#34;runtime_version\u0026#34;, \u0026#34;platform\u0026#34;, \u0026#34;is_active\u0026#34;, \u0026#34;-created_at\u0026#34;]) ] Script publish tải asset lên song song:\nwith ThreadPoolExecutor(max_workers=10) as executor: futures = {executor.submit(upload_asset, asset): asset for asset in assets} for future in as_completed(futures): # Track progress Với một bản update điển hình có khoảng 50 asset, thời gian publish giảm từ chừng 2 phút xuống còn 15 giây. Khâu phân phối thì không phải làm gì cả: Tigris tự cache asset ở các edge location gần người dùng của bạn.\nQuy trình làm việc hằng ngày # Trong lúc phát triển # # 1. Make code changes in mobile app cd mobile-app \u0026amp;\u0026amp; git commit -am \u0026#34;Fix bug\u0026#34; # 2. Publish OTA update to local environment yarn publish-update:ios # 3. Test on device # App automatically downloads and applies update Đưa lên production # # 1. Publish to production yarn publish-update:prod:ios # 2. Monitor # Check Django admin for update records # Verify assets in Tigris dashboard Rollback # # Mark problematic update as inactive in Django admin # or via management shell: python manage.py shell \u0026gt;\u0026gt;\u0026gt; from jobs.models import ExpoUpdate \u0026gt;\u0026gt;\u0026gt; bad_update = ExpoUpdate.objects.get(id=\u0026#34;uuid-here\u0026#34;) \u0026gt;\u0026gt;\u0026gt; bad_update.is_active = False \u0026gt;\u0026gt;\u0026gt; bad_update.save() # Clients will now receive the previous active update Chi phí thực tế # Curtain Estimator hiện có khoảng 500 người dùng hoạt động. Trên Tigris, ~200 MB các bản update tích lũy từ trước tới giờ tốn chừng $0.02/tháng, còn ~50 GB egress mỗi tháng từ lượt tải update thì khoảng $1.00. Phần Django chạy ké trên instance Fly.io mà API của tôi vốn đang chạy, nên không phát sinh thêm đồng nào. Tính tròn thì tổng cộng là một đô mỗi tháng. Với mức sử dụng tương tự, EAS Update sẽ rơi vào khoảng $300–500/năm, nên có thể nói hệ thống này hoàn vốn gần như ngay lập tức.\nGiám sát và gỡ lỗi # Phần này chẳng có gì cầu kỳ. Viewset ghi log mọi request lấy manifest:\nlogger.info(f\u0026#34;Manifest request: platform={platform}, runtime={runtime_version}\u0026#34;) Trên thực tế, Django admin chính là dashboard. Chỉ cần đăng ký các model là bạn xem và lọc được mọi thứ:\n@admin.register(ExpoUpdate) class ExpoUpdateAdmin(admin.ModelAdmin): list_display = [\u0026#34;platform\u0026#34;, \u0026#34;runtime_version\u0026#34;, \u0026#34;is_active\u0026#34;, \u0026#34;created_at\u0026#34;] list_filter = [\u0026#34;platform\u0026#34;, \u0026#34;is_active\u0026#34;, \u0026#34;runtime_version\u0026#34;] search_fields = [\u0026#34;description\u0026#34;] Còn ở phía client, expo-updates sẽ cho bạn biết nó đang thấy gì:\nimport * as Updates from \u0026#39;expo-updates\u0026#39;; Updates.checkForUpdateAsync().then(update =\u0026gt; { console.log(\u0026#39;Update available:\u0026#39;, update.isAvailable); console.log(\u0026#39;Manifest:\u0026#39;, update.manifest); }); Những cái bẫy # Lệch runtime version # Lỗi hay gặp nhất cũng là lỗi khó phát hiện nhất: client chỉ tải những update khớp với runtime version của nó. Nếu ứng dụng đã cài đang ở runtime 1.0.0 mà bạn lại publish cho 1.0.1, thì sẽ chẳng có update nào về máy, mà cũng chẳng có lỗi nào báo ra. Hãy giữ runtime version đồng bộ với các bản build, và chỉ tăng nó khi native code thay đổi.\nTimestamp createdAt # Client expo-updates so sánh createdAt trong manifest với commitTime của bundle nhúng sẵn, và chỉ áp dụng update nào có createdAt mới hơn. Trong viewset, tôi ghi đè giá trị này bằng timestamp lấy từ cơ sở dữ liệu:\nmanifest_data[\u0026#34;createdAt\u0026#34;] = update.created_at.strftime(\u0026#34;%Y-%m-%dT%H:%M:%S.%fZ\u0026#34;) Khi phát triển ở local, nếu bạn build binary sau khi đã publish OTA, hãy publish lại bản OTA đó để timestamp của nó mới hơn.\nGiá trị key của asset trong manifest # Trường key trên mỗi asset trong manifest là thứ expo-updates dùng để cache, và giá trị này phải là một hash tất định (MD5 của tên tệp là được), chứ không phải UUID ngẫu nhiên hay một chuỗi tùy ý. Làm sai chỗ này thì client có thể không cache hoặc không lấy lại được asset đúng cách, và update sẽ âm thầm hỏng sau lần tải thành công đầu tiên. Cảm ơn bạn đọc Raphael Mutschler đã chỉ ra lỗi này: update bên anh ấy chỉ chạy được đúng một lần, rồi anh mới tìm ra nguyên nhân.\nCấu hình ứng dụng trong expoClient # Nếu ứng dụng của bạn dùng Linking, Constants, hay bất cứ thứ gì đọc app config lúc runtime, thì trường extra.expoClient trong manifest phải chứa app config đó. Thiếu trường này, lúc đầu ứng dụng có thể vẫn mở lên bình thường, nhưng sau khi bị đóng thì crash hoặc không chịu mở lại nữa. Lý do là expo-updates thay manifest nhúng sẵn bằng manifest OTA, và nếu thiếu expoClient thì các API kia mất luôn phần cấu hình mà chúng dựa vào. Lỗi này cũng do Raphael Mutschler phát hiện.\nDọn dẹp asset # Các bản update cũ cứ thế chất đống trên Tigris. Hiện tại tôi vẫn dọn bằng tay:\n# Delete updates older than 30 days from datetime import timedelta from django.utils import timezone cutoff = timezone.now() - timedelta(days=30) old_updates = ExpoUpdate.objects.filter(created_at__lt=cutoff, is_active=False) for update in old_updates: # Delete assets from S3 for asset in update.assets.all(): s3_client.delete_object(Bucket=bucket_name, Key=asset.file_path) # Delete DB records update.delete() Rõ ràng đoạn này nên được đưa vào một scheduled task. Chỉ là tôi chưa có thời gian làm.\nNhững thứ tôi muốn làm tiếp # Rollout theo từng đợt # Chỉ cần thêm một trường rollout_percentage là có thể phát hành update cho một phần người dùng trước:\nrollout_percentage = models.IntegerField(default=100) # In the manifest view: if update.rollout_percentage \u0026lt; 100: # Hash user ID and check if they\u0026#39;re in rollout group user_hash = int(hashlib.sha256(user_id.encode()).hexdigest(), 16) if (user_hash % 100) \u0026gt;= update.rollout_percentage: return Response(status=204) # No update Tách riêng update cho staging # Thêm một trường environment thì các bản build staging có thể nhận update khác với production:\nenvironment = models.CharField(max_length=20, default=\u0026#34;production\u0026#34;) # Client sends environment in custom header environment = request.META.get(\u0026#34;HTTP_X_UPDATE_ENVIRONMENT\u0026#34;, \u0026#34;production\u0026#34;) update = ExpoUpdate.objects.filter(environment=environment, ...).first() Thống kê lượt tải # Và một model nhỏ nữa sẽ trả lời đàng hoàng câu hỏi “rốt cuộc đã có ai nhận được bản này chưa?”:\nclass ExpoUpdateDownload(models.Model): update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE) user_id = models.CharField(max_length=255, null=True) platform = models.CharField(max_length=10) downloaded_at = models.DateTimeField(auto_now_add=True) Tổng kết # Toàn bộ hệ thống chỉ gồm khoảng 150 dòng model và view Django, chừng 200 dòng script publish, và một đô mỗi tháng tiền hạ tầng. Ít hơn nhiều so với tôi nghĩ lúc bắt đầu, và từ đó tới nay nó vẫn lặng lẽ làm tốt việc của mình cho Curtain Estimator trên cả hai nền tảng.\nPhần mã ở trên được lấy nguyên từ chính ứng dụng production đó, nên bạn cứ điều chỉnh cho hợp với dự án của mình. Còn nếu muốn bắt đầu từ một thứ đã chạy được sẵn, bạn đọc Raphael Mutschler cũng đã công bố một bản implement độc lập tại expo-ota-server.\nMuốn xem spec đầy đủ, bạn tham khảo đặc tả giao thức Expo Updates.\nToàn bộ script # Script publish là cái tôi dùng hằng ngày. Còn script sync là phương án cũ hơn: nó bỏ qua API import và copy thẳng record update vào cơ sở dữ liệu production qua flyctl ssh.\npublish-ota-update.sh # #!/bin/bash # Publish OTA Update Script # # Usage: # ./scripts/publish-ota-update.sh # Both platforms (LOCAL) # ./scripts/publish-ota-update.sh ios # iOS only (LOCAL) # ./scripts/publish-ota-update.sh ios --production # iOS to PRODUCTION # ./scripts/publish-ota-update.sh --dry-run # Test without uploading # ./scripts/publish-ota-update.sh ios --description \u0026#34;Bug fixes\u0026#34; set -e GREEN=\u0026#39;\\033[0;32m\u0026#39; YELLOW=\u0026#39;\\033[1;33m\u0026#39; BLUE=\u0026#39;\\033[0;34m\u0026#39; RED=\u0026#39;\\033[0;31m\u0026#39; NC=\u0026#39;\\033[0m\u0026#39; # Parse arguments PLATFORM=\u0026#34;all\u0026#34; PRODUCTION=false DRY_RUN=false DESCRIPTION=\u0026#34;\u0026#34; while [[ $# -gt 0 ]]; do case $1 in ios|android|all) PLATFORM=\u0026#34;$1\u0026#34;; shift ;; --production|--prod) PRODUCTION=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --description) DESCRIPTION=\u0026#34;$2\u0026#34;; shift 2 ;; -h|--help) echo \u0026#34;Usage: $0 [ios|android|all] [--production] [--dry-run] [--description \\\u0026#34;msg\\\u0026#34;]\u0026#34; exit 0 ;; *) echo -e \u0026#34;${RED}Unknown: $1${NC}\u0026#34;; exit 1 ;; esac done # Auto-detect project root (support running from mobile-app/ via yarn) if [ -d \u0026#34;mobile-app\u0026#34; ]; then : # already at project root elif [ -d \u0026#34;../mobile-app\u0026#34; ]; then cd .. else echo -e \u0026#34;${RED}Error: Run from project root or mobile-app/${NC}\u0026#34; \u0026amp;\u0026amp; exit 1 fi docker info \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 || { echo -e \u0026#34;${RED}Error: Docker not running${NC}\u0026#34;; exit 1; } # Log file — verbose output goes here, terminal gets summary only LOG_FILE=\u0026#34;ota-publish-$(date +%Y%m%d-%H%M%S).log\u0026#34; echo -e \u0026#34;${BLUE}═══ Expo OTA Publisher ═══${NC}\u0026#34; echo -e \u0026#34;Platform: ${PLATFORM} Production: ${PRODUCTION} Log: ${LOG_FILE}\u0026#34; echo \u0026#34;\u0026#34; # ── Step 1: Export with production env vars ── echo -e \u0026#34;${YELLOW}Step 1: Exporting mobile app...${NC}\u0026#34; cd mobile-app # Load production env vars from eas.json (adapt these to your app\u0026#39;s env vars) if [ -f \u0026#34;eas.json\u0026#34; ] \u0026amp;\u0026amp; command -v jq \u0026amp;\u0026gt; /dev/null; then for key in $(jq -r \u0026#39;.build.production.env // {} | keys[]\u0026#39; eas.json); do export \u0026#34;$key\u0026#34;=\u0026#34;$(jq -r \u0026#34;.build.production.env.$key\u0026#34; eas.json)\u0026#34; done fi OTA_EXPORT_DIR=\u0026#34;dist-ota\u0026#34; if [ \u0026#34;$PLATFORM\u0026#34; = \u0026#34;all\u0026#34; ]; then npx expo export --platform ios --output-dir \u0026#34;$OTA_EXPORT_DIR\u0026#34; \u0026gt;\u0026gt; \u0026#34;../$LOG_FILE\u0026#34; 2\u0026gt;\u0026amp;1 npx expo export --platform android --output-dir \u0026#34;$OTA_EXPORT_DIR\u0026#34; \u0026gt;\u0026gt; \u0026#34;../$LOG_FILE\u0026#34; 2\u0026gt;\u0026amp;1 else npx expo export --platform \u0026#34;$PLATFORM\u0026#34; --output-dir \u0026#34;$OTA_EXPORT_DIR\u0026#34; \u0026gt;\u0026gt; \u0026#34;../$LOG_FILE\u0026#34; 2\u0026gt;\u0026amp;1 fi cd .. echo -e \u0026#34;${GREEN}✓ Export complete${NC}\u0026#34; # ── Step 2: Upload to Tigris + create DB records ── echo -e \u0026#34;${YELLOW}Step 2: Publishing to Tigris...${NC}\u0026#34; CMD_ARGS=\u0026#34;--platform $PLATFORM --export-dir mobile-app/$OTA_EXPORT_DIR\u0026#34; [ \u0026#34;$DRY_RUN\u0026#34; = true ] \u0026amp;\u0026amp; CMD_ARGS=\u0026#34;$CMD_ARGS --dry-run\u0026#34; [ -n \u0026#34;$DESCRIPTION\u0026#34; ] \u0026amp;\u0026amp; CMD_ARGS=\u0026#34;$CMD_ARGS --description \\\u0026#34;$DESCRIPTION\\\u0026#34;\u0026#34; [ \u0026#34;$PRODUCTION\u0026#34; = true ] \u0026amp;\u0026amp; CMD_ARGS=\u0026#34;$CMD_ARGS --production-sync\u0026#34; PUBLISH_OUTPUT=$(eval docker compose exec -T django python manage.py publish_expo_update $CMD_ARGS 2\u0026gt;\u0026amp;1) echo \u0026#34;$PUBLISH_OUTPUT\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; # Print key lines to terminal echo \u0026#34;$PUBLISH_OUTPUT\u0026#34; | grep -E \u0026#39;✓ Published:|Deactivated|OTA_S3_KEY=|DRY RUN|ERROR|Failed\u0026#39; || true # ── Step 3 (production only): Sync via API endpoint ── if [ \u0026#34;$PRODUCTION\u0026#34; = true ] \u0026amp;\u0026amp; [ \u0026#34;$DRY_RUN\u0026#34; = false ]; then echo -e \u0026#34;${YELLOW}Step 3: Syncing to production...${NC}\u0026#34; # Extract S3 key(s) from management command output S3_KEYS=$(echo \u0026#34;$PUBLISH_OUTPUT\u0026#34; | grep -o \u0026#39;OTA_S3_KEY=[^ ]*\u0026#39; | sed \u0026#39;s/OTA_S3_KEY=//\u0026#39;) [ -z \u0026#34;$S3_KEYS\u0026#34; ] \u0026amp;\u0026amp; echo -e \u0026#34;${RED}Error: No OTA_S3_KEY found in publish output${NC}\u0026#34; \u0026amp;\u0026amp; exit 1 # Read OTA_IMPORT_SECRET from .env if [ -f \u0026#34;.env\u0026#34; ]; then OTA_IMPORT_SECRET=$(grep -E \u0026#39;^OTA_IMPORT_SECRET=\u0026#39; .env | sed \u0026#39;s/^OTA_IMPORT_SECRET=//\u0026#39;) fi [ -z \u0026#34;$OTA_IMPORT_SECRET\u0026#34; ] \u0026amp;\u0026amp; echo -e \u0026#34;${RED}Error: OTA_IMPORT_SECRET not found in .env${NC}\u0026#34; \u0026amp;\u0026amp; exit 1 PROD_URL=\u0026#34;https://your-app.fly.dev/api/expo-updates/import-update/\u0026#34; for S3_KEY in $S3_KEYS; do RESPONSE=$(curl -s -w \u0026#34;\\n%{http_code}\u0026#34; -X POST \u0026#34;$PROD_URL\u0026#34; \\ -H \u0026#34;Authorization: Bearer $OTA_IMPORT_SECRET\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#34;{\\\u0026#34;s3_key\\\u0026#34;: \\\u0026#34;$S3_KEY\\\u0026#34;}\u0026#34;) HTTP_CODE=$(echo \u0026#34;$RESPONSE\u0026#34; | tail -1) BODY=$(echo \u0026#34;$RESPONSE\u0026#34; | sed \u0026#39;$d\u0026#39;) echo \u0026#34;$BODY\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; if [ \u0026#34;$HTTP_CODE\u0026#34; = \u0026#34;200\u0026#34; ]; then UPDATE_ID=$(echo \u0026#34;$BODY\u0026#34; | python3 -c \u0026#34;import sys,json; print(json.load(sys.stdin)[\u0026#39;update_id\u0026#39;])\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;unknown\u0026#34;) PLAT=$(echo \u0026#34;$BODY\u0026#34; | python3 -c \u0026#34;import sys,json; print(json.load(sys.stdin)[\u0026#39;platform\u0026#39;])\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;unknown\u0026#34;) NOTIF_COUNT=$(echo \u0026#34;$BODY\u0026#34; | python3 -c \u0026#34;import sys,json; print(json.load(sys.stdin).get(\u0026#39;notifications_sent\u0026#39;, 0))\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;0\u0026#34;) echo -e \u0026#34;${GREEN}✓ ${PLAT}: ${UPDATE_ID}${NC}\u0026#34; echo -e \u0026#34;${GREEN}✓ Sent ${NOTIF_COUNT} push notification(s) to production users${NC}\u0026#34; else echo -e \u0026#34;${RED}Error: HTTP $HTTP_CODE${NC}\u0026#34; echo \u0026#34;$BODY\u0026#34; exit 1 fi done fi echo \u0026#34;\u0026#34; echo -e \u0026#34;${GREEN}═══ ✓ Done ═══${NC}\u0026#34; echo -e \u0026#34;Full log: ${LOG_FILE}\u0026#34; sync-ota-to-prod.sh # #!/bin/bash # Sync OTA Update to Production Database # This script copies an OTA update record from local to production database # The bundles are already in Tigris (shared between local and production) set -e # Colors RED=\u0026#39;\\033[0;31m\u0026#39; GREEN=\u0026#39;\\033[0;32m\u0026#39; YELLOW=\u0026#39;\\033[1;33m\u0026#39; BLUE=\u0026#39;\\033[0;34m\u0026#39; NC=\u0026#39;\\033[0m\u0026#39; # Get the update ID from arguments or use the latest UPDATE_ID=\u0026#34;$1\u0026#34; if [ -z \u0026#34;$UPDATE_ID\u0026#34; ]; then echo -e \u0026#34;${YELLOW}No update ID provided, using latest iOS update...${NC}\u0026#34; UPDATE_ID=$(docker compose exec -T django python manage.py shell -c \u0026#34; from jobs.models import ExpoUpdate update = ExpoUpdate.objects.filter(platform=\u0026#39;ios\u0026#39;).order_by(\u0026#39;-created_at\u0026#39;).first() print(update.id if update else \u0026#39;\u0026#39;) \u0026#34; | tail -1 | tr -d \u0026#39;\\r\\n\u0026#39;) fi echo -e \u0026#34;${BLUE}Syncing OTA Update to Production${NC}\u0026#34; echo -e \u0026#34;${BLUE}Update ID: $UPDATE_ID${NC}\u0026#34; echo \u0026#34;\u0026#34; # Export the update data from local database echo -e \u0026#34;${YELLOW}Step 1/2: Exporting from local database...${NC}\u0026#34; docker compose exec -T django python manage.py shell -c \u0026#34; import json from jobs.models import ExpoUpdate, ExpoUpdateAsset try: update = ExpoUpdate.objects.get(id=\u0026#39;$UPDATE_ID\u0026#39;) except ExpoUpdate.DoesNotExist: print(\u0026#39;ERROR: Update not found\u0026#39;) exit(1) # Export update print(json.dumps({ \u0026#39;id\u0026#39;: str(update.id), \u0026#39;runtime_version\u0026#39;: update.runtime_version, \u0026#39;platform\u0026#39;: update.platform, \u0026#39;is_active\u0026#39;: update.is_active, \u0026#39;manifest_data\u0026#39;: update.manifest_data, \u0026#39;description\u0026#39;: update.description, \u0026#39;assets\u0026#39;: [ { \u0026#39;id\u0026#39;: str(asset.id), \u0026#39;hash\u0026#39;: asset.hash, \u0026#39;key\u0026#39;: asset.key, \u0026#39;content_type\u0026#39;: asset.content_type, \u0026#39;file_extension\u0026#39;: asset.file_extension, \u0026#39;file_path\u0026#39;: asset.file_path, \u0026#39;file_size\u0026#39;: asset.file_size, } for asset in update.assets.all() ] })) \u0026#34; \u0026gt; /tmp/ota_sync_$UPDATE_ID.json # Check if export succeeded if [ ! -s /tmp/ota_sync_$UPDATE_ID.json ]; then echo -e \u0026#34;${RED}Failed to export update${NC}\u0026#34; exit 1 fi echo -e \u0026#34;${GREEN}✓ Exported update data${NC}\u0026#34; echo \u0026#34;\u0026#34; # Import to production database echo -e \u0026#34;${YELLOW}Step 2/2: Importing to production database...${NC}\u0026#34; # Create Python script for import cat \u0026gt; /tmp/ota_import.py \u0026lt;\u0026lt; \u0026#39;EOFPY\u0026#39; import json from jobs.models import ExpoUpdate, ExpoUpdateAsset with open(\u0026#39;/tmp/ota_data.json\u0026#39;, \u0026#39;r\u0026#39;) as f: data = json.load(f) # Create or update the ExpoUpdate record update, created = ExpoUpdate.objects.update_or_create( id=data[\u0026#39;id\u0026#39;], defaults={ \u0026#39;runtime_version\u0026#39;: data[\u0026#39;runtime_version\u0026#39;], \u0026#39;platform\u0026#39;: data[\u0026#39;platform\u0026#39;], \u0026#39;is_active\u0026#39;: data[\u0026#39;is_active\u0026#39;], \u0026#39;manifest_data\u0026#39;: data[\u0026#39;manifest_data\u0026#39;], \u0026#39;description\u0026#39;: data[\u0026#39;description\u0026#39;], } ) print(f\u0026#34;Update: {\u0026#39;created\u0026#39; if created else \u0026#39;updated\u0026#39;}\u0026#34;) print(f\u0026#34; ID: {update.id}\u0026#34;) print(f\u0026#34; Platform: {update.platform}\u0026#34;) print(f\u0026#34; Runtime: {update.runtime_version}\u0026#34;) print(f\u0026#34; Description: {update.description}\u0026#34;) # Create assets assets_created = 0 for asset_data in data[\u0026#39;assets\u0026#39;]: _, created = ExpoUpdateAsset.objects.update_or_create( id=asset_data[\u0026#39;id\u0026#39;], defaults={ \u0026#39;update\u0026#39;: update, \u0026#39;hash\u0026#39;: asset_data[\u0026#39;hash\u0026#39;], \u0026#39;key\u0026#39;: asset_data[\u0026#39;key\u0026#39;], \u0026#39;content_type\u0026#39;: asset_data[\u0026#39;content_type\u0026#39;], \u0026#39;file_extension\u0026#39;: asset_data[\u0026#39;file_extension\u0026#39;], \u0026#39;file_path\u0026#39;: asset_data[\u0026#39;file_path\u0026#39;], \u0026#39;file_size\u0026#39;: asset_data[\u0026#39;file_size\u0026#39;], } ) if created: assets_created += 1 print(f\u0026#34;Assets: {assets_created} created, {len(data[\u0026#39;assets\u0026#39;]) - assets_created} updated\u0026#34;) print(f\u0026#34;✓ OTA update successfully synced to production!\u0026#34;) EOFPY # Copy JSON to temp location and import flyctl ssh console -C \u0026#34;cat \u0026gt; /tmp/ota_data.json\u0026#34; \u0026lt; /tmp/ota_sync_$UPDATE_ID.json flyctl ssh console -C \u0026#34;cat \u0026gt; /tmp/ota_import.py\u0026#34; \u0026lt; /tmp/ota_import.py flyctl ssh console -C \u0026#34;python manage.py shell \u0026lt; /tmp/ota_import.py\u0026#34; # Cleanup rm /tmp/ota_sync_$UPDATE_ID.json /tmp/ota_import.py echo \u0026#34;\u0026#34; echo -e \u0026#34;${GREEN}╔════════════════════════════════════════════════════════════╗${NC}\u0026#34; echo -e \u0026#34;${GREEN}║ ✓ OTA Update Synced to Production! ║${NC}\u0026#34; echo -e \u0026#34;${GREEN}╚════════════════════════════════════════════════════════════╝${NC}\u0026#34; echo \u0026#34;\u0026#34; # Verify echo -e \u0026#34;${BLUE}Verifying production...${NC}\u0026#34; flyctl ssh console -C \u0026#34;python manage.py shell -c \\\u0026#34; from jobs.models import ExpoUpdate count = ExpoUpdate.objects.count() latest = ExpoUpdate.objects.order_by(\u0026#39;-created_at\u0026#39;).first() print(f\u0026#39;Total OTA updates: {count}\u0026#39;) if latest: print(f\u0026#39;Latest: {latest.platform} - {latest.description}\u0026#39;) \\\u0026#34;\u0026#34; ","date":"22/02/2026","description":"Cách tôi tự dựng một server OTA update riêng cho Expo bằng Django và Tigris S3, và vì sao mỗi tháng chi phí vận hành chỉ khoảng một đô.","externalUrl":null,"permalink":"/vi/posts/2026/2026-02-22-self-hosted-ota-updates/","section":"Bài viết","summary":"","title":"Tự dựng server OTA update cho Expo bằng Django và Tigris","type":"posts"},{"content":"Trợ lý AI trong ứng dụng Curtain Estimator của tôi chạy hoàn toàn trên điện thoại. Không có dữ liệu nào rời khỏi máy. Người dùng chỉ cần gõ tiếng Anh thông thường là có thể tạo job, tìm khách hàng và quản lý dự án, và mọi thứ vẫn chạy ngon lành kể cả khi bật chế độ máy bay.\nTrong bài này, tôi sẽ kể lại cách mình làm ra nó với llama.rn (binding React Native cho llama.cpp) và Qwen 1.7B, một mô hình nhỏ nhưng hóa ra làm được nhiều hơn tôi tưởng.\n📝 Cập nhật: Bản hiện tại đã chuyển sang Qwen3.5-2B-GGUF thay cho Qwen3-1.7B ban đầu. Tôi cũng bỏ luôn mẹo chèn /no_think vào message, giờ thì tắt thinking mode đúng cách qua tham số chat_template_kwargs:\nchat_response = client.chat.completions.create( model=\u0026#34;Qwen/Qwen3.5-27B\u0026#34;, messages=messages, max_tokens=32768, temperature=0.7, top_p=0.8, presence_penalty=1.5, extra_body={ \u0026#34;top_k\u0026#34;: 20, \u0026#34;chat_template_kwargs\u0026#34;: {\u0026#34;enable_thinking\u0026#34;: False}, }, ) Gọn gàng hơn nhiều so với kiểu lén nhét cờ điều khiển vào prompt.\nVì sao tôi không muốn dùng AI trên cloud cho việc này # Cách làm tính năng AI quen thuộc trong ứng dụng di động là: người dùng gõ tin nhắn, ứng dụng gửi nó sang OpenAI, Anthropic hoặc Google, nhận câu trả lời về, và hóa đơn lại nhích lên một chút.\nNhưng với một ứng dụng mà thợ lắp rèm dùng để điều hành việc làm ăn của chính họ, cách làm đó có vấn đề. Tên, địa chỉ, số điện thoại của khách hàng bị gửi cho bên thứ ba. Chi tiết dự án, giá cả và ghi chú thì nằm trên máy chủ của người khác. Rồi còn những câu hỏi về GDPR và nơi lưu trữ dữ liệu mà thật lòng tôi không muốn mất thời gian vào. Chưa kể hóa đơn API cứ tăng dần theo số người dùng.\nCách còn lại là chạy mô hình ngay trên điện thoại của người dùng, và tôi đã chọn cách đó.\nVì sao đến giờ chuyện này mới khả thi # Nếu là một năm trước thì tôi đã chẳng buồn thử. Nhưng có ba thứ thay đổi gần như cùng lúc. Thứ nhất, mô hình quantize đã đủ nhỏ: Qwen 1.7B ở mức Q4_K_M chỉ nặng 1.1 GB, nhẹ hơn phần lớn các game, tải về một lần là nằm luôn trong bộ nhớ của ứng dụng. Thứ hai, GPU trên điện thoại đã đủ nhanh: llama.cpp dùng Metal trên iOS và Vulkan trên Android, và trên iPhone 14 Pro tôi đo được khoảng 15 token mỗi giây, thừa sức để stream câu trả lời theo thời gian thực. Thứ ba, mô hình nhỏ giờ đã thực sự dùng được: Qwen 1.7B làm theo chỉ dẫn có cấu trúc, parse được JSON và suy luận qua nhiều bước. Business logic cần đúng những khả năng đó. Có ai bắt nó làm thơ đâu.\nChọn mô hình # Tôi đã thử bốn mô hình nhỏ trước khi chốt Qwen3-1.7B.\nTinyLlama 1.1B (637 MB) chạy nhanh nhất, nhưng cứ bịa ra ID khách hàng và bỏ sót các trường bắt buộc, nên đầu ra có cấu trúc thì không tin được. Phi-3-mini (1.8 GB) suy luận tốt nhưng nói nhiều không chịu nổi; hỏi một câu đơn giản, nó trả về cả bài văn 200 chữ trong khi tôi chỉ cần 20. Gemma-2B (1.2 GB) nhanh, phân loại chính xác, nhưng gọi hàm lại yếu: nó không sinh ra đều đặn được các tag \u0026lt;action\u0026gt; mà hệ thống tool của tôi cần. Qwen3-1.7B (1.1 GB) thì vừa khéo: đầu ra có cấu trúc ổn định, làm theo chỉ dẫn chính xác, lại hỗ trợ chain-of-thought qua tag \u0026lt;think\u0026gt;.\nBản quantize Q4_K_M dùng trọng số 4-bit kết hợp k-means clustering, nhỏ hơn khoảng 75% so với bản full precision mà chất lượng chỉ giảm chừng 5%.\nCài đặt llama.rn # llama.rn bọc llama.cpp lại để dùng được trong React Native, và cài đặt là phần dễ nhất:\nnpm install llama.rn cd ios \u0026amp;\u0026amp; pod install Còn bản thân mô hình thì được tải xuống ở lần dùng đầu tiên:\nconst MODEL_URL = \u0026#34;https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf\u0026#34;; const MODEL_PATH = FileSystem.documentDirectory + \u0026#34;llama-models/Qwen3-1.7B-Q4_K_M.gguf\u0026#34;; const downloadModel = async () =\u0026gt; { const downloadResumable = FileSystem.createDownloadResumable( MODEL_URL, MODEL_PATH, {}, (progress) =\u0026gt; { const pct = progress.totalBytesWritten / progress.totalBytesExpectedToWrite; setDownloadProgress(pct); } ); await downloadResumable.downloadAsync(); }; Qua WiFi thì mất 2-3 phút, qua LTE thì khoảng 5-8 phút. Khi tệp đã nằm trên máy, bạn load nó lên kèm GPU acceleration:\nconst ctx = await initLlama({ model: MODEL_PATH, n_ctx: 8192, // 8K context window n_gpu_layers: 99, // Use GPU for all layers }); Đừng bỏ qua n_gpu_layers: 99. Đẩy toàn bộ việc tính toán sang Metal/Vulkan thay vì để CPU gánh sẽ giúp tốc độ tăng khoảng 5 lần.\nStreaming inference # Chẳng ai muốn ngồi nhìn vòng xoay loading suốt mười giây, nên câu trả lời được stream ra từng token một:\nlet fullResponse = \u0026#34;\u0026#34;; await llamaContext.completion( { messages: [ { role: \u0026#34;system\u0026#34;, content: systemPrompt }, { role: \u0026#34;user\u0026#34;, content: \u0026#34;Create a job for John Smith\u0026#34; } ], n_predict: 512, temperature: 0.7, top_p: 0.8, }, (data) =\u0026gt; { // Called for each token fullResponse += data.token; setStreamingText(fullResponse); } ); Trên điện thoại đời mới, token đầu tiên xuất hiện sau khoảng 200ms (đó là thời gian xử lý prompt), sau đó mỗi token mất 50-80ms. So với bất cứ thứ gì phải chờ mạng, cảm giác gần như tức thì.\nFunction calling mà mô hình nhỏ làm được thật # Function calling kiểu GPT-4 dựa vào JSON schema, và mô hình nhỏ gặp nó là \u0026ldquo;ngã\u0026rdquo;: JSON sai định dạng, thiếu trường, đủ cả. Vì vậy tôi dùng một giao thức đơn giản hơn, dựa trên XML:\nconst systemPrompt = `You are an AI assistant. When you need to take an action, output: \u0026lt;action\u0026gt;{\u0026#34;type\u0026#34;:\u0026#34;search_customers\u0026#34;,\u0026#34;query\u0026#34;:\u0026#34;Smith\u0026#34;}\u0026lt;/action\u0026gt; Available actions: - search_customers: {\u0026#34;type\u0026#34;:\u0026#34;search_customers\u0026#34;,\u0026#34;query\u0026#34;:\u0026#34;John\u0026#34;} - create_job: {\u0026#34;type\u0026#34;:\u0026#34;create_job\u0026#34;,\u0026#34;customer_id\u0026#34;:5,\u0026#34;alias\u0026#34;:\u0026#34;Living Room\u0026#34;} - update_job: {\u0026#34;type\u0026#34;:\u0026#34;update_job\u0026#34;,\u0026#34;job_id\u0026#34;:\u0026#34;J-0042\u0026#34;,\u0026#34;status\u0026#34;:\u0026#34;quoting\u0026#34;} Rules: 1. ONE action tag per response, at the very end 2. When searching/creating → end with \u0026lt;action\u0026gt; 3. When just chatting → no action tag `; Tag XML thắng vì mấy lý do rất bình thường: \u0026lt;action\u0026gt; và \u0026lt;/action\u0026gt; là dấu phân cách rõ ràng, parse chỉ cần một biểu thức regex, các ví dụ trong prompt kiêm luôn vai trò tài liệu, và cách này vẫn chạy được khi mô hình nói thêm vài câu lan man quanh tag.\nPhần parse thì đơn giản đúng như vẻ ngoài của nó:\nfunction parseAction(text: string): Action | null { const match = text.match(/\u0026lt;action\u0026gt;([\\s\\S]*?)\u0026lt;\\/action\u0026gt;/); if (!match) return null; try { return JSON.parse(match[1].trim()); } catch { return null; } } Khi mô hình trả về một action, tôi thực thi nó rồi đưa kết quả ngược lại vào cuộc hội thoại:\nconst action = parseAction(modelResponse); if (action) { const result = await executeAction(action); // Inject result as a system message conversationHistory.push({ role: \u0026#34;user\u0026#34;, content: `[TOOL_RESULT] ${result.message}\\n\\n→ NEXT: Tell the user what happened.` }); // Continue generation await generateNextResponse(); } Chừng đó đã đủ để chạy các luồng xử lý nhiều bước đúng nghĩa:\nNgười dùng: \u0026ldquo;Create a job for Smith\u0026rdquo;\nMô hình: \u0026lt;action\u0026gt;{\u0026quot;type\u0026quot;:\u0026quot;search_customers\u0026quot;,\u0026quot;query\u0026quot;:\u0026quot;Smith\u0026quot;}\u0026lt;/action\u0026gt; Hệ thống: [TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89) Mô hình: \u0026ldquo;I found two Smiths—Jane and Bob. Which one?\u0026rdquo; Người dùng: \u0026ldquo;Jane\u0026rdquo; Mô hình: \u0026lt;action\u0026gt;{\u0026quot;type\u0026quot;:\u0026quot;create_job\u0026quot;,\u0026quot;customer_id\u0026quot;:42}\u0026lt;/action\u0026gt; Hệ thống: [TOOL_RESULT] Job J-0073 created Mô hình: \u0026ldquo;Done! Created job J-0073 for Jane Smith.\u0026rdquo; Chain-of-thought với tag \u0026lt;think\u0026gt; # Mô hình nhỏ làm tốt hơn thấy rõ khi bạn bắt nó suy nghĩ thành lời. Qwen có sẵn thinking mode:\nconst systemPrompt = `Before each response, wrap your reasoning in \u0026lt;think\u0026gt; tags: \u0026lt;think\u0026gt; INTENT: what does the user want? HAVE: what data do I already have? NEED: what\u0026#39;s still missing? DECISION: call tool | ask user | just respond \u0026lt;/think\u0026gt; Then output your actual response.`; Bên trong, mô hình sẽ sinh ra nội dung trông như thế này:\n\u0026lt;think\u0026gt; INTENT: create a job HAVE: customer query \u0026#34;Smith\u0026#34; NEED: exact customer_id DECISION: search first \u0026lt;/think\u0026gt; \u0026lt;action\u0026gt;{\u0026#34;type\u0026#34;:\u0026#34;search_customers\u0026#34;,\u0026#34;query\u0026#34;:\u0026#34;Smith\u0026#34;}\u0026lt;/action\u0026gt; Tôi lọc bỏ các tag \u0026lt;think\u0026gt; trước khi hiển thị bất cứ thứ gì lên giao diện, nhưng trong lúc phát triển thì vẫn để chúng hiện ra. Độ ổn định khác nhau một trời một vực: mô hình tự lý luận qua từng bước rồi mới chốt một action.\nGiữ các cuộc hội thoại dài nằm gọn trong 8K # Một khi kết quả từ tool bắt đầu dồn vào, context window 8K đầy nhanh hơn bạn tưởng. Tầm lượt thứ 30 là tôi chạm trần.\nCách tôi xử lý là nén cuộc hội thoại lại, bằng cách nhờ chính mô hình tự tóm tắt:\nconst compactPrompt = `Summarize this conversation in under 200 words. Include: user\u0026#39;s goal, customers/jobs created (with IDs), and any unfinished tasks. ${conversationHistory.map(m =\u0026gt; `${m.role}: ${m.content}`).join(\u0026#39;\\n\\n\u0026#39;)}`; const summary = await chat([{ role: \u0026#34;user\u0026#34;, content: compactPrompt }]); // Replace history with summary conversationHistory = [ { role: \u0026#34;user\u0026#34;, content: `[CONTEXT SUMMARY]\\n${summary}\\n[END SUMMARY]` } ]; Một cuộc hội thoại 50 tin nhắn được ép xuống còn khoảng 150 token, mà mô hình vẫn tiếp tục bình thường, không quên mình đã tạo những job nào, với ID khách hàng nào.\nLưu phiên chat # Các phiên chat được lưu xuống backend Django của tôi:\ninterface SessionMessage { role: \u0026#34;user\u0026#34; | \u0026#34;assistant\u0026#34;; content: string; // Stripped for display raw: string; // Full with \u0026lt;think\u0026gt; and \u0026lt;action\u0026gt; tags } await api.createAIChatSession({ organization_id: orgId, title: firstUserMessage.slice(0, 60), messages: [ { role: \u0026#34;user\u0026#34;, content: userText, raw: userText }, { role: \u0026#34;assistant\u0026#34;, content: cleanedResponse, raw: fullModelOutput } ] }); Nhờ lưu cả content lẫn raw, lịch sử chat hiển thị lại gọn gàng trên giao diện, còn khi mở lại một phiên cũ thì mô hình được nạp lại đầy đủ ngữ cảnh, kể cả các action. Người dùng chuyển qua lại giữa các cuộc hội thoại bằng một dropdown lịch sử, na ná như ChatGPT.\nBenchmark từ production # iPhone 14 Pro (A16 Bionic, 6GB RAM):\nLoad mô hình: ~3 giây Token đầu tiên: 180-220ms Streaming: 14-16 token/giây Bộ nhớ: ~1.8 GB Samsung Galaxy S23 (Snapdragon 8 Gen 2, 8GB RAM):\nLoad mô hình: ~4 giây Token đầu tiên: 250-300ms Streaming: 10-12 token/giây Bộ nhớ: ~2.1 GB iPhone 11 (A13, 4GB RAM):\nLoad mô hình: ~6 giây Token đầu tiên: 400-500ms Streaming: 6-8 token/giây Bộ nhớ: ~2.2 GB (thỉnh thoảng crash vì thiếu bộ nhớ) Kinh nghiệm của tôi là cần từ 6GB RAM trở lên thì mới chạy mượt. Máy 4GB vẫn chạy được, nhưng nhiều khả năng bạn sẽ phải unload mô hình mỗi khi ứng dụng chuyển xuống background.\nQuản lý bộ nhớ # iOS sẵn sàng kill ứng dụng của bạn nếu nó ôm quá nhiều bộ nhớ, nên cứ mỗi lần ứng dụng rời khỏi foreground là tôi giải phóng mô hình:\nuseEffect(() =\u0026gt; { const subscription = AppState.addEventListener(\u0026#34;change\u0026#34;, (nextAppState) =\u0026gt; { if (nextAppState === \u0026#34;background\u0026#34;) { llamaContext?.release(); // Free ~2 GB } else if (nextAppState === \u0026#34;active\u0026#34;) { loadModel(); // Reload when foregrounded } }); return () =\u0026gt; subscription.remove(); }, []); Ảnh hưởng tới pin # Chạy inference ngay trên máy thì cũng tốn pin chứ không phải miễn phí. Đây là số liệu tôi đo được khi dùng thực tế:\nMức độ sử dụng Hao pin thêm Nhẹ (5-10 truy vấn/ngày) \u0026lt;1% mỗi ngày Vừa (20-30 truy vấn/ngày) ~3-4% mỗi ngày Nặng (50+ truy vấn/ngày) ~6-8% mỗi ngày Inference bằng GPU tốn điện hơn CPU nhưng xong nhanh hơn nhiều, mà người dùng thì rõ ràng thích được trả lời tức thì, nên tôi vẫn giữ GPU.\nSau hai tháng, với khoảng 200 người dùng beta # Cơ cấu các truy vấn làm tôi hơi bất ngờ:\n\u0026ldquo;Create a job for [customer name]\u0026rdquo; - 68% số truy vấn \u0026ldquo;Find jobs for [customer]\u0026rdquo; - 15% \u0026ldquo;Update job [ID] to [status]\u0026rdquo; - 9% Câu hỏi chung chung về ứng dụng - 8% 91% truy vấn thành công ngay lần đầu. Số còn lại thất bại vì ba lý do: không tìm thấy khách hàng do người dùng gõ sai tên (4%), tràn context trong những cuộc hội thoại rất dài (3%), và mô hình tự bịa ra ID khách hàng (2%).\nLỗi cuối cùng này trước đây còn tệ hơn nhiều. Chuyện bịa ID gần như biến mất sau khi tôi trả kết quả tool về ở dạng tường minh, kiểu customer_id=42, thay vì một câu văn. Với mô hình nhỏ, bạn phải dựng sẵn \u0026ldquo;giàn giáo\u0026rdquo; đó ra thật rõ ràng.\nChi phí bao nhiêu # Chạy trên máy: $0/tháng tiền hạ tầng, $0 cho mỗi người dùng ngoài lần tải 1.1 GB duy nhất, và có bao nhiêu người muốn cài ứng dụng thì nó cũng scale được bấy nhiêu.\nCòn nếu làm bản cloud, ước tính sơ sơ thế này: một cuộc hội thoại trung bình khoảng ~2,000 token, 200 người dùng mỗi người 30 cuộc hội thoại một tháng là 6,000 cuộc hội thoại, tức 12M token/tháng. Tính ra khoảng $18/tháng với GPT-3.5, $360/tháng với GPT-4, hoặc $180/tháng với Claude. Ở quy mô này thì chưa đến mức phá sản, nhưng chạy trên máy thì hoàn vốn ngay từ ngày đầu, và kể cả khi lên tới 10,000 người dùng cũng vẫn không tốn đồng nào.\nNhững giới hạn nên biết # Năng lực của mô hình. Qwen 1.7B làm các tác vụ có cấu trúc rất tốt, nhưng lại rất dở ở suy luận phức tạp qua nhiều bước, nhớ kiến thức (nó không phải công cụ tìm kiếm), viết sáng tạo và những việc cần sự tinh tế. Cứ thiết kế tính năng xoay quanh những gì mô hình nhỏ làm giỏi là ổn.\nYêu cầu thiết bị. Tối thiểu là 3 GB RAM và 2 GB bộ nhớ trống; thực tế thì bạn nên có 6 GB RAM, chip 64-bit và hỗ trợ GPU. Máy đời cũ như iPhone 8 hay Galaxy S9 sẽ chạy rất chật vật, nên hãy kiểm tra khả năng của thiết bị trước và có phương án dự phòng cho êm.\nCập nhật mô hình. Mỗi lần phát hành mô hình mới là người dùng lại phải tải lại 1.1 GB. Tôi đánh version cho mô hình ngay trong đường dẫn lưu trữ:\nconst MODEL_PATH = `${FileSystem.documentDirectory}llama-models/v2/Qwen3-1.7B-Q4_K_M.gguf`; Mỗi version mô hình mới thì tăng số trong đường dẫn lên. Mô hình cũ sẽ tự được dọn đi khi người dùng gỡ ứng dụng.\niOS vs Android # Trên iOS, Metal chạy nhanh hơn Android khoảng 20%, bộ nhớ được quản lý tốt hơn, và mặc định mô hình sẽ được backup lên iCloud (tắt đi bằng NSURLIsExcludedFromBackupKey, trừ khi bạn muốn ngốn dung lượng backup của người dùng). Android dùng Vulkan/OpenCL thì chênh lệch giữa các máy lớn hơn nhiều: một số GPU đời cũ hoàn toàn không hỗ trợ Vulkan và phải chạy bằng CPU, còn bộ nhớ thì không được backup tự động. Hãy kiểm thử trên cả hai nền tảng; khoảng cách giữa một máy Android tốt và một máy Android kém còn lớn hơn cả khoảng cách giữa hai nền tảng.\nMẹo gỡ lỗi # 1. Bật verbose logging\nconst ctx = await initLlama({ model: MODEL_PATH, n_ctx: 8192, n_gpu_layers: 99, verbose: true, // Logs every token + timings }); 2. Theo dõi số token\nconst tokens = fullResponse.split(/\\s+/).length * 1.3; // Rough estimate console.log(`Generated ${tokens} tokens in ${duration}ms`); 3. Giám sát bộ nhớ\nimport { MemoryInfo } from \u0026#39;react-native-device-info\u0026#39;; const memoryUsage = await MemoryInfo.getUsedMemory(); console.log(`Memory: ${(memoryUsage / 1024 / 1024).toFixed(0)} MB`); 4. Kiểm thử khi offline\nBật chế độ máy bay rồi dùng ứng dụng như thật. Mô hình phải load được từ cache, inference phải chạy xong, và chỉ có phần lưu xuống backend là được phép lỗi, mà có lỗi thì cũng phải lỗi êm.\nTôi muốn đưa nó đi xa hơn thế nào # llama.cpp đã hỗ trợ mô hình thị giác (LLaVA, Qwen2-VL), và một tính năng kiểu \u0026ldquo;đây là ảnh vải rèm, thêm vào job giúp tôi\u0026rdquo; rõ ràng rất hợp với ứng dụng này. Tôi cũng muốn thử fine-tune một LoRA adapter dựa trên các mô tả job cũ; tôi đoán độ chính xác với các truy vấn trong lĩnh vực rèm cửa sẽ tăng 20-30%. Xa hơn nữa là Whisper.cpp để có giao diện giọng nói chạy hoàn toàn offline, và có thể là federated learning, để mô hình tốt dần lên nhờ nhiều người dùng mà không có chút dữ liệu thô nào rời khỏi điện thoại của họ.\nBạn có nên làm theo không? # Nếu bạn đang làm ứng dụng doanh nghiệp có dữ liệu nhạy cảm, quy trình cần chạy được khi offline, hay các tác vụ có cấu trúc như phân loại và nhập liệu, và nhất là nếu bạn lo chi phí API khi scale lên, thì câu trả lời là có, cách này dùng được ngay từ hôm nay.\nCòn nếu bạn cần chat tự do về đủ mọi chủ đề, cứ dùng Claude hoặc GPT-4. Tương tự với tác vụ đòi hỏi nhiều kiến thức (mô hình nhỏ đơn giản là không biết nhiều), khi bạn buộc phải hỗ trợ máy cấu hình thấp, hoặc khi kiến thức chuyên ngành của bạn thay đổi nhanh hơn tốc độ bạn phát hành bản cập nhật mô hình.\nToàn bộ hệ thống này tôi làm mất khoảng 12 tiếng. Chi phí vận hành là $0 mỗi tháng, dùng được cả trên máy bay, và dữ liệu khách hàng không bao giờ rời khỏi tay khách hàng. Khó mà chê được.\nCó câu hỏi hay góp ý? Bạn tìm tôi ở @jaredlynskey. Thư viện llama.rn nằm ở github.com/mybigday/llama.rn.\n","date":"30/01/2026","description":"Cách tôi cho một trợ lý AI dùng Qwen 1.7B chạy hoàn toàn trên điện thoại, ngay trong ứng dụng báo giá rèm cửa của mình, bằng llama.rn và một giao thức tool-calling tự viết.","externalUrl":null,"permalink":"/vi/posts/2026/2026-01-30-on-device-ai/","section":"Bài viết","summary":"","title":"AI chạy ngay trên điện thoại trong React Native: Qwen 1.7B với llama.cpp","type":"posts"},{"content":"Giữa \u0026ldquo;chạy được trên máy tôi\u0026rdquo; và \u0026ldquo;chạy được trên production\u0026rdquo; luôn có một khoảng cách, và hệ thống giám sát lẽ ra phải lấp được khoảng cách đó. Nhưng phần lớn thời gian nó không làm được, vì người ta xây nó như một công cụ cho đội vận hành, chứ không phải thứ lập trình viên tìm tới khi có gì đó hỏng.\nTôi vừa chuyển sang làm freelance mảng DevOps và hạ tầng, và ở bất cứ nơi nào tôi đặt chân tới, hệ thống giám sát luôn là một trong những thứ đầu tiên tôi xem xét — nó cho bạn biết về một đội còn nhiều hơn cả tệp README. Kiểu thất bại gần như lúc nào cũng giống nhau: công cụ thì có đủ, nhưng chẳng khớp gì với cách lập trình viên thật sự làm việc. Có người mất ba tiếng cho một lỗi mà nếu xem log production thì năm phút là xong. Sự cố được phát hiện qua ticket hỗ trợ thay vì qua cảnh báo. Hoặc ngược lại: mỗi ngày có tới 200 cảnh báo, phần lớn là nhiễu, nên ai cũng phớt lờ tất cả, và nửa tiếng đầu của mỗi sự cố chỉ để tìm xem cái gì bị hỏng, thay vì bắt tay vào sửa.\nMetric, log và trace — không liên kết với nhau thì cũng vô dụng # Metric cho bạn biết có gì đó không ổn (tỷ lệ lỗi tăng vọt, latency nhảy lên). Log cho bạn biết chuyện gì đã xảy ra (stack trace, payload của request, thông báo lỗi). Trace cho bạn biết lỗi nằm ở đâu (service nào, endpoint nào, câu query database nào). Mấy điều này chẳng có gì mới. Điều quan trọng là chúng có được liên kết với nhau hay không: khi một cảnh báo bắn ra, bạn phải click được từ metric sang đúng đoạn log liên quan, rồi từ đó sang trace. Nếu lập trình viên vẫn phải tự dò khớp timestamp giữa ba công cụ khác nhau, thì hệ thống đó đã thất bại, cho dù trên giấy tờ từng công cụ trông tốt đến đâu.\nDashboard cũng phải qua bài kiểm tra tương tự. CPU, memory và disk I/O quan trọng khi lên kế hoạch dung lượng, nhưng chẳng giúp ai gỡ lỗi một response 500. Những dashboard mà mọi người tự nguyện mở ra thường đặt business metric cạnh technical metric (số lượt đăng ký mỗi giờ bên cạnh tỷ lệ lỗi), đánh dấu các lần deploy gần đây lên dòng thời gian, liệt kê năm lỗi xuất hiện nhiều nhất trong giờ vừa qua kèm link tới log, và hiển thị latency theo percentile — p50, p95, p99 — thay vì giá trị trung bình. Một dashboard mà không ai tự mở ra nếu không bị nhắc thì chẳng đáng giữ lại.\nCảnh báo cũng vậy. Mỗi cảnh báo phải trả lời được hai câu hỏi: cái gì đang hỏng, và tôi nên bắt đầu tìm từ đâu?\nCảnh báo tệ: \u0026ldquo;High CPU on web-server-3\u0026rdquo;\nCảnh báo tốt: \u0026ldquo;Error rate \u0026gt; 5% on /api/payments since 14:32. Last deploy: 14:15 by @sarah. [View logs] [View trace]\u0026rdquo;\nCó vài cách giúp cảnh báo nghiêng về loại thứ hai: cảnh báo khi số liệu lệch khỏi baseline thay vì dựa vào một ngưỡng đặt tùy tiện; định tuyến theo người phụ trách (lỗi thanh toán thì gửi tới đội payments, chứ không phải cả công ty); và gom các lỗi liên quan vào một tin nhắn Slack thay vì 50 tin. Vòng phản hồi cũng phải thật ngắn — từ lúc \u0026ldquo;có gì đó hỏng\u0026rdquo; đến lúc \u0026ldquo;lập trình viên biết chuyện\u0026rdquo; nên dưới một phút. Muốn vậy thì cần stream log theo thời gian thực thay vì gom theo lô năm phút một lần, có deploy marker trên mọi dashboard, và tracing không bị đứt khi đi qua ranh giới giữa các service.\nXây cho chính những người sẽ dùng # Sai lầm lớn nhất của các đội vận hành là xây hệ thống giám sát cho chính mình. Cách sửa cũng chẳng có gì cao siêu. Hãy ngồi cạnh một lập trình viên trong lúc họ gỡ lỗi và xem họ bị kẹt ở đâu. Hỏi xem giữa lúc có sự cố thì họ hay đặt ra những câu hỏi nào — \u0026ldquo;service nào?\u0026rdquo;, \u0026ldquo;vừa thay đổi cái gì?\u0026rdquo;, \u0026ldquo;request lúc đó trông ra sao?\u0026rdquo;. Tìm hiểu xem metric nào thật sự quan trọng với sản phẩm; hiếm khi là CPU, mà thường là những thứ như tỷ lệ hoàn tất checkout hay tỷ lệ tải lên thành công.\nSau đó, gỡ bỏ những chỗ gây vướng víu. Nếu gắn instrumentation cho một service mới mà mất cả ngày, sẽ chẳng ai làm. Hãy cung cấp thư viện tự động instrument cho các framework đang dùng (Django, Express, Spring), template dashboard và cảnh báo để copy-paste, và metric tự phục vụ để không ai phải tạo ticket. Và hãy quản lý toàn bộ hệ thống như mã nguồn: cấu hình nằm trong version control, các rule cảnh báo được kiểm thử để chắc chắn chúng bắn ra đúng lúc, và nếu sau này định đi multi-region thì tính tới từ sớm.\nĐội nhỏ dùng AWS? Bắt đầu với CloudWatch # Nếu đội của bạn dưới 50 kỹ sư và đang dùng AWS, CloudWatch là điểm khởi đầu hợp lý, và tôi sẽ cố nhịn không mua thứ gì xịn hơn ngay từ ngày đầu. EC2, Lambda, RDS và ECS đều tự động đẩy dữ liệu về đó, nên bạn quan sát được hệ thống ngay mà không phải viết instrumentation. Với một đội nhỏ, chi phí rơi vào khoảng $10–50 mỗi tháng, không có phí nền tảng, và metric, log, trace (qua X-Ray) cùng alarm đều nằm chung một chỗ. Chỉ trong một buổi chiều là bạn đã dựng xong những cảnh báo và dashboard có ý nghĩa, chứ không phải mất vài tuần.\nPhần ít được để ý nhưng rất đáng dùng là CloudWatch Logs Insights — bạn truy vấn được log mà không cần dựng Elasticsearch:\nfields @timestamp, @message | filter @message like /ERROR/ | stats count() by bin(5m) Ngoài ra còn có:\nComposite alarm giúp giảm nhiễu — chỉ cảnh báo khi tỷ lệ lỗi cao và thời gian phản hồi cũng chậm đi, thay vì cảnh báo riêng cho từng điều kiện. Custom metric mới là chỗ mang lại giá trị thật. Hãy đẩy business metric (lượt đăng ký, giao dịch, mức độ sử dụng tính năng) qua CloudWatch SDK, song song với các metric hạ tầng. CloudWatch Synthetics chạy canary theo lịch, đi qua các luồng thao tác thật của người dùng, để bạn biết checkout bị hỏng trước khi người dùng phát hiện ra. X-Ray cho bạn distributed tracing mà gần như không tốn công thiết lập — đủ tốt cho phần lớn kiến trúc microservice. Bạn sẽ tự biết lúc nào mình đã vượt quá khả năng của nó: khi chuyển sang multi-cloud, khi cần phát hiện bất thường cho ra hồn, khi việc tùy biến dashboard bắt đầu thành cực hình, hoặc khi đội vượt quá 50 kỹ sư và cần các tính năng cộng tác thật sự.\nĐội lớn hơn: DataDog # DataDog đắt — với một tổ chức lớn, $20–100K+ mỗi năm là chuyện bình thường — nhưng ở quy mô lớn thì nó đáng đồng tiền. Bạn có một góc nhìn chung bao quát cả AWS, Azure, GCP, on-prem, container, serverless, database và frontend. Watchdog tự đánh dấu các bất thường mà không cần tinh chỉnh ngưỡng bằng tay, và điều này rất quan trọng vì không ai có thể tự canh hàng nghìn service bằng mắt. Bạn cũng có được lớp cộng tác mà CloudWatch còn thiếu: dashboard theo đội, RBAC, notebook điều tra dùng chung, tích hợp PagerDuty/Opsgenie, cảnh báo nhiều điều kiện, dự báo ngưỡng, cửa sổ bảo trì, và một APM đi sâu tới tận profiling ở cấp mã nguồn, kèm service map tự động sinh ra.\nVề cách triển khai, đại khái theo thứ tự tôi sẽ làm: instrument các service quan trọng trước, và gắn tag mọi thứ theo đội và môi trường ngay từ ngày đầu. Thống nhất sớm quy ước đặt tên, template dashboard, các mức độ nghiêm trọng và định nghĩa SLO — để về sau mới áp chuẩn ngược lại thì rất khổ. Nối DataDog với deploy marker từ CI/CD, hệ thống quản lý sự cố và Slack. Dành thời gian cho việc đào tạo, vì DataDog mạnh nhưng không dễ tự mày mò. Và để mắt tới hóa đơn: lọc bớt các metric nhiễu, lấy mẫu trace ở những service có lưu lượng lớn, và rà soát xem mình thật sự đang trả tiền cho những tính năng nào.\nTrước khi chốt, cũng nên xem qua vài lựa chọn khác: New Relic (tương tự, đôi khi rẻ hơn nếu tracing với lưu lượng lớn), Dynatrace (mạnh về AIOps, được chuộng trong ngành tài chính), Splunk (phân tích log thuộc hàng tốt nhất, nhất là khi đội bảo mật đã dùng sẵn), và Grafana Cloud (lựa chọn tự nhiên nếu bạn đang dùng Prometheus/Loki).\nTrên thực tế, nhiều đội chọn mô hình lai: CloudWatch cho các service gốc của AWS vì nó tự động và rẻ, DataDog cho tầng ứng dụng, rồi nạp metric của CloudWatch vào DataDog để có một góc nhìn thống nhất. Chẳng hào nhoáng gì, nhưng chạy tốt.\nBắt đầu từ con số không # Nếu là tôi, tôi sẽ làm theo thứ tự này. Trước tiên, nói chuyện với lập trình viên xem điều gì làm họ khổ nhất mỗi khi có sự cố. Định nghĩa SLO cho những luồng quan trọng — với login, checkout, search thì thế nào mới gọi là \u0026ldquo;đang chạy tốt\u0026rdquo;? Instrument những luồng quan trọng đó trước. Tiếp theo là thêm distributed tracing; trong kiến trúc microservice, đây là khoản đầu tư cho việc gỡ lỗi có ROI cao nhất. Viết runbook cho mỗi cảnh báo, để bạn của lúc 3 giờ sáng biết cần kiểm tra cái gì. Sau đó đặt lịch review mỗi quý để xóa các cảnh báo đã lỗi thời, chỉnh lại các ngưỡng đã bị lệch, và xác nhận dashboard vẫn khớp với kiến trúc hiện tại.\nCòn đây là những cái bẫy mà tôi cứ phải kéo các đội ra mãi: dùng quá nhiều công cụ (ba công cụ phối hợp ăn ý vẫn hơn sáu công cụ rời rạc), biểu đồ không có baseline (500 rps là bình thường hay là tăng vọt gấp 10 lần?), giám sát những thứ người dùng không hề cảm nhận được (disk I/O trên một container stateless), và không có phương án dự phòng cho chính hệ thống giám sát — nếu hệ thống cảnh báo chết giữa lúc đang có sự cố, bạn sẽ mù đúng vào lúc không thể để mù.\nKhông cần tới công cụ xịn nhất cho bất cứ điều gì ở trên. Điều cần là lập trình viên nhận được đúng thông tin họ cần, ở đúng chỗ họ sẽ nhìn vào, và thật nhanh.\n","date":"12/10/2025","description":"Những điều tôi rút ra sau nhiều năm chứng kiến các hệ thống giám sát bỏ rơi lập trình viên, và cách tôi sẽ xây một hệ thống không như vậy.","externalUrl":null,"permalink":"/vi/posts/2025/2025-10-13-devops-monitoring/","section":"Bài viết","summary":"","title":"Dựng hệ thống giám sát mà lập trình viên thật sự dùng tới","type":"posts"},{"content":"Năm nay, từ khi chuyển sang làm engineering manager, tôi ngồi ở phía nhà tuyển dụng nhiều hơn hẳn. Bài học lớn nhất cho tới giờ là: để ứng viên nói chung chung về CV của mình thì gần như chẳng biết thêm được gì. CV cho bạn biết người ta đã làm những gì, nhưng gần như không nói gì về cách họ làm việc.\nĐể lấp khoảng trống đó, công cụ tốt nhất tôi tìm được là câu hỏi hành vi theo format STAR. STAR là viết tắt của Situation, Task, Action, Result (tình huống, nhiệm vụ, hành động, kết quả): bạn đề nghị ứng viên kể một câu chuyện có thật trong quá khứ, rồi dẫn họ đi qua đủ bốn bước đó. Nghe thì giống mấy thứ rập khuôn của phòng nhân sự — mà đúng là rập khuôn của phòng nhân sự thật — nhưng nó buộc người ta phải nói cụ thể, và tín hiệu đáng giá nằm chính ở những chi tiết cụ thể.\nMột câu trả lời tốt trông thế nào # Lấy ví dụ câu hỏi \u0026ldquo;Kể tôi nghe về một lần bạn xử lý một con bug phức tạp.\u0026rdquo; Khi nghe, bạn chú ý tới tình huống (lúc đó chuyện gì đang diễn ra và vì sao nó quan trọng), nhiệm vụ mà chính ứng viên phải chịu trách nhiệm, những việc họ đã làm — \u0026ldquo;tôi\u0026rdquo; chứ không phải \u0026ldquo;chúng tôi\u0026rdquo; — và kết quả, tốt nhất là có con số đi kèm.\nMột câu trả lời tốt nghe đại loại như thế này, về technical debt:\n\u0026ldquo;Deployment pipeline của bọn tôi mất 45 phút, mà mấy cái test chập chờn khiến lập trình viên thường phải chạy lại build vài lần. Tôi theo dõi lượng thời gian bị mất trong một tháng, ra khoảng 15 giờ mỗi người. Tôi trình bày con số đó với ban lãnh đạo và đề xuất dành hai sprint để chạy test song song và sửa những test chập chờn nhất. Thời gian deploy giảm xuống còn 12 phút, và tần suất deploy của bọn tôi tăng gấp đôi.\u0026rdquo;\nĐủ cả tình huống, nhiệm vụ, hành động, kết quả, gói gọn trong năm câu, và phần đóng góp của chính ứng viên thì rõ mồn một. Đặt nó cạnh câu \u0026ldquo;pipeline bọn tôi chậm nên bọn tôi sửa\u0026rdquo; là bạn hiểu ngay vì sao cái khung này đáng dùng.\nNhững câu hỏi tôi hay quay lại # Thay vì cố hỏi bao quát mọi thứ, tôi xếp câu hỏi theo những năng lực mà đội thật sự đang cần lúc này:\nNăng lực Câu hỏi Giải quyết vấn đề \u0026ldquo;Kể tôi nghe về một lần bạn phải gỡ lỗi sự cố trên production trong lúc bị áp lực.\u0026rdquo; Phối hợp \u0026ldquo;Kể về một dự án mà bạn phải làm việc với một stakeholder khó tính.\u0026rdquo; Dẫn dắt kỹ thuật \u0026ldquo;Kể tôi nghe về một lần bạn tác động được tới một quyết định kỹ thuật dù không có quyền hạn trực tiếp.\u0026rdquo; Khả năng học nhanh \u0026ldquo;Kể về một lần bạn phải làm quen với một công nghệ lạ, thật nhanh.\u0026rdquo; Tinh thần làm chủ \u0026ldquo;Kể tôi nghe về một lần thứ bạn làm ra bị hỏng. Khi đó bạn đã làm gì?\u0026rdquo; Câu cuối là câu tôi thích nhất. Cách một người kể về thất bại cho bạn biết nhiều hơn cả ba câu chuyện thành công, mà lại rất khó tập dượt trước.\nBuổi phỏng vấn thật sự nằm ở đâu # Hiếm khi ứng viên tự kể trọn cả mạch STAR mà không cần gợi ý, và thế cũng chẳng sao, vì những câu hỏi đào sâu mới là buổi phỏng vấn thật. Ai đó nói \u0026ldquo;bọn tôi đã cải thiện hiệu năng\u0026rdquo; thì hỏi xem cải thiện bao nhiêu, và họ đo bằng cách nào. Cái gì cũng là \u0026ldquo;chúng tôi\u0026rdquo; thì hỏi xem riêng bản thân họ đã làm gì. Câu chuyện khép lại gọn gàng đến mức đáng ngờ thì hỏi xem lần sau họ sẽ làm gì khác đi.\nCòn một điều nữa mà tôi phải mất một thời gian mới học được: hỏi mọi ứng viên cùng một bộ câu hỏi, và chấm câu trả lời theo một thang điểm đơn giản. Nghe thì máy móc, nhưng đó là cách công bằng duy nhất để so sánh mọi người. Không thì rốt cuộc bạn sẽ chỉ nghiêng về người mình vừa nói chuyện gần đây nhất.\nKhông thứ nào ở trên thay được bài đánh giá kỹ thuật; bạn vẫn cần xem người ta có thật sự làm ra được sản phẩm hay không. Nhưng câu hỏi giả định thì dễ chém gió, còn những gì đã làm trong quá khứ thì khó mà bịa. Sau một năm phỏng vấn theo cách này, tôi tin một câu chuyện đã được hỏi kỹ hơn nhiều so với một câu trả lời trau chuốt về điểm mạnh và điểm yếu.\nĐọc thêm # Trên blog này:\nCoffee chat: bước đệm trước quy trình tuyển dụng — làm quen với ứng viên trước khi quy trình chính thức bắt đầu Ưu tiên sự gắn kết của team trong engineering management — tuyển người dựa trên việc họ sẽ hợp với đội đến đâu Lợi ích của việc thực tập ở startup — bối cảnh hữu ích khi đánh giá ứng viên mới vào nghề Retrospective: sức mạnh của vòng lặp feedback — những thói quen góp ý ấy cũng áp dụng được cho chính quy trình phỏng vấn của bạn Nguồn bên ngoài:\nThe STAR Method: The Secret to Acing Your Next Job Interview — hướng dẫn của The Muse, viết cho phía ứng viên Google re:Work: Structured Interviewing — những nghiên cứu đứng sau phương pháp phỏng vấn có cấu trúc First Round Review: 40 Favorite Interview Questions — một kho câu hỏi tha hồ mà mượn Joel Spolsky: The Guerrilla Guide to Interviewing — một bài kinh điển mà giờ đọc vẫn còn đúng ","date":"19/12/2024","description":"Ghi chép về cách phỏng vấn hành vi theo format STAR: những câu tôi hay hỏi, và điều tôi thật sự chú ý khi nghe câu trả lời.","externalUrl":null,"permalink":"/vi/posts/2024/2024-12-19-star-interviews/","section":"Bài viết","summary":"","title":"Dùng phương pháp STAR để phỏng vấn lập trình viên","type":"posts"},{"content":"Đội nào cũng phải ra quyết định. Nhưng phần lớn các đội lại quên mất vì sao mình đã quyết như vậy. Sáu tháng sau, có người hỏi \u0026ldquo;sao hồi đó mình chọn PostgreSQL mà không chọn MongoDB?\u0026rdquo;, chẳng ai còn nhớ những ràng buộc lúc ấy, thế là cả cuộc tranh luận lại diễn ra từ đầu, mà thường còn ít người tham gia hơn lần trước.\nTài liệu ra quyết định sinh ra để giải quyết đúng chuyện đó. Gọi là ADR, RFC hay decision log cũng được, cái tên không quan trọng. Quan trọng là ghi lại bối cảnh, các phương án và lập luận ngay tại thời điểm lựa chọn. Năm nay, khi quản lý một đội lập trình viên, tôi càng thấm giá trị của loại tài liệu này hơn bao giờ hết: có nó thì tổ chức có trí nhớ thật, không có thì chỉ còn lại những lời truyền miệng.\nVì sao đáng bỏ công # Cái lợi dễ thấy nhất là không phải cãi nhau về cùng một chuyện hai lần. \u0026ldquo;Ơ, chiến lược versioning cho API mình chốt rồi mà nhỉ?\u0026rdquo; Khi có một bản ghi rõ ràng về điều đã quyết và lý do, chuyện gì đã xong sẽ nằm yên, và cả đội dồn sức vào làm thay vì quay lại đi con đường cũ.\nCái lợi âm thầm hơn thì phải một thời gian mới lộ ra. Dự án kéo dài hàng tháng, hàng năm, người đến rồi người đi, thứ còn lại chỉ là mã nguồn. Mã nguồn cho bạn biết điều gì đã được quyết, nhưng không bao giờ cho biết vì sao. Tài liệu ra quyết định thì giữ lại được lập luận: lúc đó có những ràng buộc gì, phương án nào đã bị loại, ai là người chốt. Người mới vào có bối cảnh để hiểu, thay vì chỉ nghe kể lại. Những quyết định liên quan tới nhiều đội cũng nhận được sự đồng thuận thật, vì ai cũng thấy các yếu tố đã được cân nhắc thế nào, chứ không chỉ thấy kết luận cuối cùng. Và khi tài liệu ghi rõ ai ra quyết định, ai đóng góp ý kiến, thì phần việc tiếp theo sẽ có người thật sự chịu trách nhiệm.\nKhi nào nên viết # Không phải quyết định nào cũng đáng một tài liệu. Tôi chỉ viết khi quyết định đó:\nTốn kém nếu muốn quay đầu: chọn tech stack (framework, cơ sở dữ liệu, nhà cung cấp cloud), chọn pattern kiến trúc (microservices hay monolith, event-driven hay request-response), chọn nhà cung cấp bên thứ ba, hay cách tiếp cận về bảo mật và tuân thủ Có tác động lâu dài: hợp đồng API mà các hệ thống bên ngoài phụ thuộc vào, data model trải qua nhiều service, hoặc những thay đổi phá vỡ các pattern đã thành nếp Liên quan tới nhiều đội: bất cứ thứ gì cần nhiều đội hoặc các stakeholder không làm kỹ thuật cùng đồng ý, các quyết định về phân bổ nguồn lực và thứ tự ưu tiên Là kết quả của một đợt spike: khi một đợt tìm hiểu có giới hạn thời gian vừa kết thúc, hãy ghi lại những gì tìm được và quyết định rút ra trước khi mọi người quên sạch Với những quyết định dễ đảo ngược, những chi tiết triển khai nội bộ trong đội, hay những thứ gần như không ảnh hưởng ra ngoài phạm vi trực tiếp của bạn, thì bỏ qua thủ tục. Một tài liệu không ai cần tới thì chỉ là bài tập về nhà.\nVòng đời # ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Draft │ ──► │ Review │ ──► │ Approved │ ──► │ Implemented │ │ Proposed │ │ Feedback │ │ Accepted │ │ Completed │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ ▼ ┌─────────────┐ │ Rejected/ │ │ Superseded │ └─────────────┘ Người viết phác thảo vấn đề và các giải pháp có thể chọn. Sau đó các stakeholder review. Đây là giai đoạn làm nên giá trị của cả quá trình, vì đây là lúc các giả định bị chất vấn và những điểm mù lộ ra. Người ra quyết định (thường là tech lead, kiến trúc sư hoặc product owner) chốt lần cuối, đội bắt tay triển khai, và nếu thực tế buộc phải thay đổi giữa chừng thì tài liệu được cập nhật theo. Có tài liệu chết ngay ở vòng review, có tài liệu về sau bị thay thế khi hoàn cảnh đổi khác. Trường hợp nào cũng phải đánh dấu cho rõ, vì một tài liệu \u0026ldquo;accepted\u0026rdquo; đã lỗi thời chính là cái bẫy cho người tiếp theo tìm thấy nó.\nTài liệu thế nào thì đáng đọc # Bắt đầu từ vấn đề, đừng bắt đầu từ giải pháp bạn thích nhất. Mình đang giải quyết chuyện gì, vì sao lúc này chuyện đó quan trọng, và nếu không làm gì thì sẽ ra sao? Khi mọi người còn chưa thống nhất vấn đề là gì, thì tranh luận về giải pháp chỉ là diễn kịch.\nTiếp theo, đưa ra các phương án thật sự, ít nhất hai hoặc ba, kể cả khi bạn đã nghiêng hẳn về một cái. Mỗi phương án cần có: mô tả ngắn, ưu và nhược điểm, ước lượng công sức sơ bộ (ước theo cỡ áo S/M/L/XL là đủ), cùng các rủi ro và cách bạn sẽ giảm thiểu chúng. Có ghi lại các phương án thay thế thì người đọc sau này mới hiểu vì sao phương án được chọn lại thắng.\nNói thẳng ra những đánh đổi. \u0026ldquo;Chúng tôi chọn ra thị trường nhanh hơn, chấp nhận kiến trúc không được đẹp.\u0026rdquo; \u0026ldquo;Chúng tôi chấp nhận vận hành phức tạp hơn để đổi lấy khả năng scale tốt hơn.\u0026rdquo; \u0026ldquo;Chúng tôi ưu tiên trải nghiệm của lập trình viên hơn hiệu năng thuần.\u0026rdquo; Chỉ vài câu như vậy thôi đã giúp người đọc sau này nhiều hơn cả chục trang phân tích.\nNeo cuộc thảo luận vào những thứ cụ thể: đoạn mã minh hoạ giải pháp sẽ trông ra sao, sơ đồ tương tác giữa các hệ thống, link tới các bản proof-of-concept từ những đợt spike. Thảo luận chung chung thì chỉ sinh ra quyết định chung chung.\nCuối cùng, đặt hạn chót: khi nào phải ra quyết định, ai phải góp ý trước ngày nào, và những việc gì đang bị kẹt lại trong lúc chờ. Quyết định mà không có hạn chót thì sẽ bị treo mãi.\nVí dụ: Architecture Decision Record (ADR) # Dưới đây là một ví dụ thực tế về tài liệu ra quyết định:\n# ADR-001: API Authentication Strategy **Status:** Accepted **Date:** 2024-11-15 **Decision Maker:** Sarah Chen (Platform Architect) **Contributors:** Backend Team, Security Team, Mobile Team ## Context Our public API currently uses API keys for authentication. As we expand to support third-party integrations and mobile apps, we need a more robust authentication mechanism that supports: - Token expiration and refresh - Scoped permissions - User-level authentication (not just service-level) ## Decision We will implement OAuth 2.0 with JWT tokens for API authentication. ## Options Considered ### Option 1: OAuth 2.0 with JWT (Recommended) **Description:** Industry-standard protocol with self-contained tokens | Pros | Cons | |------|------| | Industry standard, well-documented | More complex initial implementation | | Self-contained tokens reduce database lookups | Tokens cannot be revoked instantly | | Broad library support | Requires refresh token management | **Effort:** Medium (2-3 sprints) ### Option 2: Session-based Authentication **Description:** Traditional server-side sessions with cookies | Pros | Cons | |------|------| | Simple to implement | Not suitable for mobile apps | | Easy to revoke sessions | Requires sticky sessions or shared session store | | Familiar to most developers | Doesn\u0026#39;t scale as well | **Effort:** Small (1 sprint) ### Option 3: Custom Token System **Description:** Build our own token-based authentication | Pros | Cons | |------|------| | Fully customizable | Reinventing the wheel | | No external dependencies | Security risks from custom implementation | **Effort:** Large (4+ sprints) ## Consequences - Mobile team can implement standard OAuth flows - We\u0026#39;ll need to set up a token refresh mechanism - API documentation will need updates for OAuth flows - Existing API key users will need a migration path (6-month deprecation) ## References - [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) - Internal spike document: [Authentication Options Spike](/spikes/auth-spike-2024) - Security team review: SEC-2024-042 Template, cứ lấy về mà dùng # Lấy cái này làm điểm xuất phát, rồi cắt gọt cho vừa với đội của bạn:\nMục Nội dung Tên quyết định/vấn đề [Tiêu đề ngắn gọn, nói rõ nội dung] Trạng thái [Draft / In Review / Accepted / Rejected / Superseded] Mức độ ảnh hưởng [Cao / Trung bình / Thấp] Người phụ trách [Người dẫn dắt quá trình ra quyết định] Người ra quyết định [Người có quyền quyết cuối cùng] Hạn chót [Ngày phải ra quyết định] Mô tả vấn đề # Mình đang giải quyết vấn đề gì? Vì sao nó quan trọng? Không làm gì thì phải trả giá thế nào?\nBối cảnh # Nêu bối cảnh, lịch sử và các chi tiết liên quan. Dẫn link tới những tài liệu liên quan, các quyết định trước đó, hoặc kết quả spike.\nRàng buộc # Giải pháp nào cũng phải tuân theo những giới hạn hay yêu cầu gì?\nRàng buộc về ngân sách Yêu cầu về tiến độ Ràng buộc kỹ thuật (hệ thống hiện có, kỹ năng của đội) Yêu cầu về tuân thủ hoặc bảo mật Các giải pháp đã cân nhắc # Phương án Mô tả Ưu điểm Nhược điểm Công sức Rủi ro Phương án 1 Mô tả Ưu A, Ưu B Nhược A, Nhược B S/M/L/XL Thấp/TB/Cao Phương án 2 Mô tả Ưu A, Ưu B Nhược A, Nhược B S/M/L/XL Thấp/TB/Cao Phương án 3 Mô tả Ưu A, Ưu B Nhược A, Nhược B S/M/L/XL Thấp/TB/Cao Đề xuất # Nêu phương án bạn đề xuất, và tóm tắt vì sao với các ràng buộc và đánh đổi hiện có, đó là lựa chọn tốt nhất.\nQuyết định # Chốt xong thì ghi lại quyết định cuối cùng. Nếu khác với đề xuất, giải thích lý do.\nHệ quả # Quyết định này kéo theo những gì? Cần làm tiếp những việc gì?\nViệc cần làm # Việc cần làm Người phụ trách Hạn chót Việc 1 Tên Ngày Việc 2 Tên Ngày Tài liệu tham khảo # Link tới các tài liệu liên quan, nguồn bên ngoài, kết quả spike, hoặc các quyết định trước đó.\nLàm sao để thói quen này bám rễ # Lưu tài liệu ở một chỗ cố định, chẳng hạn một thư mục riêng trong repo hay một không gian trên wiki, vì một quyết định không ai tìm thấy thì cũng như chưa từng có. Viết thật gọn nhẹ thôi; tài liệu mà mất cả mấy ngày mới viết xong thì sẽ chẳng bao giờ có ai viết. Nhắc tới chúng trong comment trong mã, commit message và pull request, để phần \u0026ldquo;vì sao\u0026rdquo; luôn đi liền với phần \u0026ldquo;cái gì\u0026rdquo;. Khi một quyết định bị thay thế, đừng xoá tài liệu cũ, mà hãy đánh dấu nó là superseded rồi link sang tài liệu mới; giữ lại lịch sử mới là mục đích. Và hãy mang các quyết định cũ ra xem lại trong những buổi retro: cái nào vẫn còn đứng vững, cái nào bây giờ bạn sẽ quyết khác đi.\nNhững kiểu thất bại thì cũng dễ đoán y như vậy. Phân tích mãi mà không quyết được: hãy đặt hạn chót, quyết dựa trên thông tin đang có, và nhớ rằng phần lớn quyết định đều có thể xem xét lại. Review cho có, chỉ để đóng dấu mà không bao giờ có phản biện thật, thì chỉ thêm quy trình chứ không thêm giá trị. Tài liệu viết xong rồi bỏ đó, không dẫn tới hành động nào, còn tệ hơn là không có tài liệu. Quá nhiều chi tiết cũng là một lỗi: tài liệu nên nói về vì sao và cái gì, không phải làm thế nào; phần triển khai là việc của technical spec. Và khi các stakeholder bất đồng, hãy ghi lại ý kiến phản đối. Quyết định có thể vẫn giữ nguyên, nhưng chính lời phản đối đó là bối cảnh mà một năm sau sẽ có người cần tới.\nHãy bắt đầu từ quyết định quan trọng tiếp theo của bạn. Cứ viết ra, dù còn thô, rồi vừa làm vừa chỉnh lại quy trình. Mỗi cuộc họp \u0026ldquo;sao hồi đó mình chọn X?\u0026rdquo; không cần diễn ra nữa vì ai đó chỉ việc mở ADR ra đọc, là thêm một tiếng đồng hồ đội bạn lấy lại được.\nĐọc thêm # Bài viết liên quan trên blog này:\nTìm hiểu về spike trong phát triển phần mềm Agile - làm một đợt spike để thu thập thông tin trước khi chốt quyết định lớn Retrospective: sức mạnh của vòng lặp feedback - xem lại các quyết định cũ như một phần của quá trình cải tiến liên tục Lên kế hoạch cho một API - áp dụng tư duy tài liệu ra quyết định vào thiết kế API Dùng GitHub để quản lý dự án - để tài liệu ra quyết định nằm ngay cạnh mã nguồn Tài liệu bên ngoài:\nDocumenting Architecture Decisions - Michael Nygard - bài viết gốc đã giúp ADR trở nên phổ biến ADR GitHub Organization - công cụ, template và ví dụ cho Architecture Decision Record Design Docs at Google - cách Google viết tài liệu thiết kế Quy trình RFC của Rust - quy trình RFC của ngôn ngữ Rust, được ghi chép rất kỹ Template Decision Record của Spotify - góc nhìn của Spotify về chuyện khi nào nên viết ADR ","date":"03/12/2024","description":"Cách viết tài liệu ra quyết định (ADR/RFC) sao cho mọi người thật sự dùng tới, kèm template và ví dụ.","externalUrl":null,"permalink":"/vi/posts/2024/2024-12-03-decision-docs/","section":"Bài viết","summary":"","title":"Viết tài liệu ra quyết định: hướng dẫn thực tế","type":"posts"},{"content":"Những lập trình viên mà tôi muốn tuyển nhất thì chẳng bao giờ xuất hiện trên các trang tuyển dụng. Họ đang cắm cúi xây dựng một thứ gì đó, khá hài lòng với chỗ làm hiện tại, và luôn dè chừng tin nhắn của recruiter, mà dè chừng cũng phải thôi. Từ khi chuyển sang làm engineering manager hồi đầu năm nay, công cụ tôi dựa vào để gặp được những người như vậy đơn giản đến mức hơi ngại nói ra: một ly cà phê.\nMột buổi coffee chat kéo dài khoảng 30–45 phút, không chính thức, ngồi ở quán cà phê hoặc gọi video. Không có bảng trắng, không có hội đồng, không có câu hỏi mẹo. Tôi kể về sản phẩm chúng tôi đang làm và vì sao lại làm nó, còn họ kể về công việc hiện tại và điều họ muốn làm tiếp theo. Cuối buổi, cả hai đều biết có nên nói chuyện sâu hơn hay không. Với tôi, tuyển dụng giống bài toán ghép cặp hơn là bài toán sàng lọc, và đây là cách nhanh nhất tôi biết để hai bên trao đổi đúng những thông tin cần cho việc ghép cặp đó.\nVì sao tôi chịu khó làm việc này # Tôi không còn coi những buổi này là tuyển dụng nữa. Đó chỉ là những cuộc trò chuyện, và thỉnh thoảng lại có một cuộc dẫn tới việc tuyển được người, có khi mười tám tháng sau, khi hoàn cảnh của người đó thay đổi. Những nguồn ứng viên đều đặn mang lại người giỏi đều được xây từ những mối quan hệ như vậy, chứ không phải từ tin đăng tuyển.\nCoffee chat cũng giải quyết luôn vấn đề của bản mô tả công việc. JD thì kiểu gì cũng phải viết chung chung. Còn khi ngồi cà phê, ứng viên có thể hỏi những câu thật sự quan trọng với họ: sáu tháng đầu thế nào thì được coi là thành công, tôi sẽ làm việc với ai, bài toán kỹ thuật khó nhằn nhất của nhóm lúc này là gì. Được nghe câu trả lời thành thật cho những câu hỏi đó, người ta thường tự biết mình có hợp hay không.\nHơn nữa, một buổi cà phê cho người ta tận mắt thấy văn hóa công ty bạn, thay vì chỉ nghe kể. Bạn có chuẩn bị trước không, có thật sự tò mò không, có tôn trọng thời gian của người khác không: những điều đó nói lên nhiều hơn bất kỳ trang tuyển dụng nào. Chiều ngược lại cũng thế. Cách một người cư xử trong một cuộc nói chuyện thoải mái cho bạn biết làm việc cùng họ sẽ ra sao, rõ hơn hẳn một buổi phỏng vấn mà họ đã tập dượt kỹ.\nCoffee chat phát huy tác dụng nhất trong mấy trường hợp: khi tiếp cận một người hoàn toàn không tìm việc, khi muốn làm quen trước với một người được giới thiệu trước khi bắt đầu quy trình chính thức, và khi nói chuyện với ai đó đang cân nhắc rẽ hướng sự nghiệp, chẳng hạn từ IC lên quản lý, từ startup sang tập đoàn, hoặc ngược lại. Khi thị trường nhân lực khan hiếm, chừng đó trường hợp đã bao gồm gần hết những người bạn muốn tuyển.\nTôi tổ chức một buổi như thế nào # Tin nhắn đầu tiên quyết định tất cả. Tin nhắn chung chung sẽ bị xóa ngay. Hãy tìm hiểu kỹ về người đó trước, và nói thẳng mục đích của mình:\n\u0026ldquo;Chào Sarah, mình có xem bài talk của bạn về distributed systems ở QCon và mấy bài blog của bạn về event sourcing. Bên mình ở [Company] cũng đang vật lộn với những bài toán tương tự trong lúc mở rộng hạ tầng thanh toán. Bạn có muốn đi cà phê 30 phút không? Không phải phỏng vấn đâu, chỉ là mình tò mò muốn nghe góc nhìn của bạn và kể bạn nghe bên mình đang làm gì.\u0026rdquo;\nTin nhắn này hiệu quả vì nó cụ thể, nói rõ mục đích ngay từ đầu, và mang lại cho người nhận một thứ gì đó (một cuộc trò chuyện về những bài toán thú vị) chứ không chỉ xin thời gian của họ. Sau đó hãy làm cho mọi thứ thật dễ dàng: đưa ra vài khung giờ để họ chọn, chọn một chỗ tiện cho họ hoặc một công cụ gọi video không cần cài đặt, và gửi lời mời qua lịch hẳn hoi để không ai phải đoán giờ đoán chỗ.\nBản thân buổi nói chuyện chỉ cần một khung sườn lỏng lẻo. Mở đầu bằng vài phút hỏi han thật lòng và lý do bạn liên hệ. Tiếp theo là câu chuyện của họ: công việc hiện tại, điều họ thích, điều khiến họ bực mình, điều họ mong muốn ở chỗ làm tiếp theo. Ở đoạn này, việc của bạn chủ yếu là lắng nghe. Sau đó đến lượt bạn: sứ mệnh của công ty, những thách thức thật mà nhóm đang gặp, kể cả những phần khó khăn, vì ứng viên đánh giá cao sự minh bạch và nhận ra ngay khi bạn đang chào hàng. Đoạn đáng giá nhất là phần trò chuyện cởi mở gần cuối: cần những điều kiện gì thì họ mới cân nhắc chuyển việc? Kết thúc bằng các bước tiếp theo rõ ràng, kể cả khi bước tiếp theo chỉ là \u0026ldquo;mình giữ liên lạc nhé\u0026rdquo;.\nTrong vòng 24 giờ, hãy nhắn lại một lời ngắn gọn có nhắc tới một chi tiết cụ thể trong buổi nói chuyện, và làm đúng những gì bạn đã hứa, dù là giới thiệu một người, gửi một đường link hay bất cứ thứ gì. Chính phần việc nhàm chán sau buổi gặp này mới thể hiện rõ nhất văn hóa của bạn.\nNếu bạn làm việc này thường xuyên, hãy giữ một bảng tính đơn giản ghi lại những người đã nói chuyện và thỉnh thoảng hỏi thăm họ, kể cả khi không có vị trí nào đang tuyển. Và đây cũng không nhất thiết là việc riêng của quản lý. Nhiều buổi cà phê hay nhất lại là khi các kỹ sư của bạn đi cà phê với những người giỏi trong mạng lưới quen biết của chính họ. Những lời giới thiệu chất lượng thường đến từ đó.\nNhững câu hỏi đáng dành thời gian # Những câu tôi hay hỏi:\n\u0026ldquo;Gần đây bài toán thú vị nhất mà bạn từng làm là gì?\u0026rdquo; \u0026ldquo;Ở công việc tiếp theo, bạn muốn điều gì khác đi?\u0026rdquo; \u0026ldquo;Bạn hay học theo cách nào? Bạn muốn phát triển chuyên môn ra sao?\u0026rdquo; \u0026ldquo;Bạn có câu hỏi nào để đánh giá xem chỗ này có hợp với mình không?\u0026rdquo; Những câu tôi sẽ hỏi nếu ngồi ở phía bên kia bàn:\n\u0026ldquo;Thách thức lớn nhất mà nhóm đang đối mặt lúc này là gì?\u0026rdquo; \u0026ldquo;Anh/chị đánh giá thế nào là thành công ở vị trí này?\u0026rdquo; \u0026ldquo;Con đường thăng tiến ở đây như thế nào?\u0026rdquo; \u0026ldquo;Vì sao anh/chị vào công ty này? Điều gì khiến anh/chị ở lại?\u0026rdquo; \u0026ldquo;Có điều gì khi làm ở đây mà có thể khiến tôi bất ngờ không?\u0026rdquo; Những cách làm hỏng buổi cà phê # Cách nhanh nhất là biến nó thành một buổi phỏng vấn trá hình. Hễ bạn bắt đầu hỏi kiểm tra kỹ năng kỹ thuật hay lồng vào mấy câu hỏi hành vi là bạn đã phá vỡ thỏa thuận ngầm giữa hai bên, nên hãy để dành những thứ đó cho quy trình chính thức. Đứng thứ hai là chào hàng quá đà: những người ở lại lâu dài là những người đã biết rõ các khó khăn ngay từ lúc nhận lời. Đến mà không chuẩn bị, hoặc im bặt sau buổi gặp, thì gây hại âm thầm hơn; buổi cà phê nào cũng góp phần định hình những gì người đó kể về bạn với mạng lưới của họ.\nTừ phía tôi, dấu hiệu đáng lo là một ứng viên không tò mò về bất cứ điều gì, một cuộc nói chuyện chỉ thuần túy trao đổi lợi ích, hoặc một người nhắc đến chỗ làm cũ nào cũng bằng cùng một giọng cay đắng. Ứng viên cũng nên có danh sách dấu hiệu của riêng mình: người đại diện công ty không giải thích được vì sao công việc này có ý nghĩa, những câu trả lời né tránh về tỷ lệ nghỉ việc hay định hướng của công ty, và bất kỳ ai chỉ lo chào mời bạn hơn là tìm hiểu bạn.\nKhông phải buổi cà phê nào cũng dẫn tới kết quả, và điều đó hoàn toàn bình thường, vì phần lớn là không. Mục đích là lần tới khi cần tuyển gấp cho một vị trí quan trọng, bạn không phải bắt đầu từ một tin tuyển dụng trống trơn, mà từ cả một loạt những cuộc trò chuyện thật. Hãy liên hệ bằng một lời mời đi cà phê, chứ không phải bằng một bản mô tả công việc.\nĐọc thêm # Bài viết liên quan trên blog này:\nDùng phương pháp STAR cho các buổi phỏng vấn lập trình viên - khi buổi cà phê chuyển thành quy trình phỏng vấn chính thức Ưu tiên sự gắn kết của nhóm trong engineering management - điều gì giúp một nhóm làm việc tốt với nhau, cũng chính là thứ bạn đang tìm khi ghép cặp Lợi ích của việc thực tập ở startup - bối cảnh hữu ích khi nói chuyện với người đang cân nhắc chuyển sang startup Tài liệu bên ngoài:\nFirst Round Review: How to Hire - các hướng dẫn tuyển dụng cho startup từ những người điều hành giàu kinh nghiệm Lara Hogan: \u0026ldquo;Questions for Our First 1:1\u0026rdquo; - viết cho các buổi 1:1, nhưng dùng khi đi cà phê cũng rất hợp Patrick McKenzie bàn về đàm phán lương - đáng đọc để hiểu góc nhìn của ứng viên Who: The A Method for Hiring - framework tuyển dụng bài bản của Geoff Smart và Randy Street Harvard Business Review: Recruiting - các bài viết dựa trên nghiên cứu về thu hút nhân tài ","date":"12/07/2024","description":"Vì sao những buổi cà phê không chính thức đã trở thành cách tôi thích nhất để làm quen với lập trình viên, tôi tổ chức chúng ra sao, và những kiểu làm hỏng buổi gặp thường gặp.","externalUrl":null,"permalink":"/vi/posts/2024/2024-07-12-coffee-chats/","section":"Bài viết","summary":"","title":"Coffee chat: làm quen với lập trình viên trước khi họ đi tìm việc","type":"posts"},{"content":"Thực tập ở công ty lớn thì bạn có một cái tên đẹp để ghi vào CV và một dự án với phạm vi rõ ràng. Còn thực tập ở startup thì bạn có tất cả những thứ còn lại.\nTôi xin nói trước là mình có phần thiên vị: phần lớn sự nghiệp của tôi gắn với các công ty nhỏ, và hiện tại tôi cũng đang quản lý một nhóm lập trình ở một công ty như thế. Nhưng mỗi lần có bạn sinh viên hỏi nên chọn chỗ thực tập nào, gần như lần nào tôi cũng khuyên chọn chỗ nhỏ hơn, phải tự xoay xở nhiều hơn.\nCông việc là thật # Ở một công ty mười người thì chẳng có ai để đùn những việc nhàm chán sang cả. Thực tập sinh cũng tự làm và ship tính năng, nói chuyện với khách hàng, và ngồi dự các cuộc họp lên kế hoạch. Bạn sẽ được đụng tới cả sản phẩm, marketing lẫn vận hành, có khi tất cả dồn vào cùng một tuần. Mọi thứ khá lộn xộn, nhưng bù lại bạn học rất nhanh.\nHay hơn nữa là những gì bạn làm ra sẽ thật sự được đưa lên chạy. Bạn có thể chỉ vào một thứ trong sản phẩm và nói \u0026ldquo;cái này là tôi làm đấy\u0026rdquo;. Cảm giác làm ra một thứ rồi tận mắt nhìn người ta dùng nó rất dễ gây nghiện, và ở công ty lớn thì hầu như chẳng bao giờ thực tập sinh được chạm tới trải nghiệm đó.\nCòn một cái lợi rất thực tế nữa: startup đang phát triển thì luôn cần người, và founder thường thích tuyển người mình đã từng làm việc cùng hơn. Một kỳ thực tập tốt hay lặng lẽ biến thành một lời mời làm việc chính thức.\nBạn được quan sát founder ở cự ly gần # Ở công ty lớn, có khi bạn chẳng bao giờ gặp mặt CEO. Ở startup thì nhiều khả năng bạn đang ngồi ngay cạnh họ. Bạn sẽ thấy họ ra quyết định thế nào, chịu áp lực ra sao, và sắp xếp thứ tự ưu tiên kiểu gì khi việc gì cũng có vẻ gấp như nhau. Chuyện này thì không giáo trình nào dạy cả.\nCơ cấu phẳng thì có cả hai mặt. Giữa bạn và lúc sản phẩm được phát hành không có ba cấp phê duyệt, nên bạn thật sự có tiếng nói về việc mình làm gì và làm như thế nào. Nhưng mặt khác, cũng sẽ chẳng ai cầm tay chỉ việc cho bạn. Có người rất hợp với môi trường như vậy; có người lại thấy nó đáng sợ. Tốt nhất là bạn nên biết mình thuộc kiểu nào trước khi nhận lời.\nNói thẳng về những điểm trừ # Lương thường thấp hơn. Quy trình gần như không có. Có thể bạn sẽ không có mentor riêng, và có khi hai năm nữa chính công ty cũng không còn. Tôi sẽ không giả vờ là mọi thứ đều màu hồng. Nhưng nếu bạn muốn hiểu một doanh nghiệp thật sự vận hành thế nào từ bên trong, thì đến giờ tôi vẫn chưa tìm ra cách nào nhanh hơn.\n","date":"01/07/2024","description":"Thực tập ở startup thực tế khác gì so với ở công ty lớn, và vì sao những đánh đổi đó là đáng.","externalUrl":null,"permalink":"/vi/posts/2024/2024-07-01-internships/","section":"Bài viết","summary":"","title":"Vì sao bạn nên thực tập ở startup","type":"posts"},{"content":"Bạn push một thay đổi CSS chỉ có đúng một dòng, thế rồi một cái nút ở tận trang khác lặng lẽ vỡ giao diện. Không test nào fail, code review cũng không phát hiện ra, và một tuần sau bạn mới biết chuyện qua phản hồi của người dùng. Nhóm nhỏ nào cũng từng dính con bug này.\nTheo sách vở thì câu trả lời là viết thêm unit test. Tôi đang quản lý một nhóm lập trình nhỏ, và nói thật là viết unit test cho từng component và từng trạng thái layout là thứ xa xỉ mà chúng tôi không kham nổi. Thay vào đó, chúng tôi dựa vào visual testing: chụp màn hình các trang, so với những ảnh baseline đã biết chắc là đúng, rồi đánh dấu bản build nếu có pixel nào xê dịch ở chỗ lẽ ra không được xê dịch. Xét trên công sức bỏ ra, tôi chưa thử cách nào khác mà hiệu quả được như vậy.\nVì sao ảnh chụp màn hình đáng đồng tiền bát gạo # Cái hay nằm ở chỗ có rất nhiều thứ bạn không cần phải viết. Visual test không kiểm tra từng phần tử riêng lẻ mà đánh giá cả trang như một tổng thể. Layout bị xô lệch, màu sắc bị đổi, giao diện responsive bị vỡ, các phần tử đè lên nhau: đó chính là những lỗi hay lọt qua code review và viết unit test để bao phủ thì cực kỳ khổ sở. Với visual test, tất cả đều hiện ra thành một bản diff ngay trong PR.\nChi phí bảo trì cũng gần như bằng không. Bạn chỉ cần đặt baseline một lần, sau đó nó chạy trên mọi PR và lần nào cũng cho ra cùng một kết quả. Giao diện thay đổi có chủ đích? Cập nhật baseline rồi làm tiếp. Thay đổi ngoài ý muốn? Bản diff đã nằm sẵn trong PR, không ai phải bấm lòng vòng qua nửa trang web mới tìm ra.\nVisual test không thay thế unit test # Unit test kiểm tra logic, còn ảnh chụp màn hình kiểm tra giao diện. Về lâu dài thì bạn sẽ cần cả hai. Nhưng nếu nhóm bạn nhỏ và phải chọn nơi để dồn những giờ làm việc đầu tiên, thì tính trên mỗi giờ thiết lập, visual test cho độ bao phủ nhiều hơn bất cứ thứ gì khác. Nguyên tắc của tôi là: unit test dành cho phần business logic mà nếu hỏng thì hậu quả thật sự nghiêm trọng, còn lại cứ để visual test lo.\nToàn bộ hệ thống chỉ có vậy: baseline, bản diff trên mỗi PR, và thỉnh thoảng bấm xác nhận \u0026ldquo;ừ, chỗ đó là cố ý\u0026rdquo;. Vậy mà số lỗi nó bắt được nhiều hơn hẳn những gì tôi dám kỳ vọng.\nThông tin thêm và các liên kết hữu ích # Visual testing với Playwright # Tài liệu về snapshot testing của Playwright: cách chụp cả trang hoặc từng phần tử, so sánh với baseline, và cập nhật baseline khi giao diện được thay đổi có chủ đích. Visual testing với Playwright\nBest practice khi kiểm thử với Playwright # Lời khuyên của Playwright cho các test có đụng tới cơ sở dữ liệu: tách riêng dữ liệu test, dọn dẹp sau khi chạy xong, và giữ các test độc lập với nhau để cả bộ test luôn đáng tin cậy. Best practice khi kiểm thử với Playwright\n","date":"09/06/2024","description":"Vì sao so sánh ảnh chụp màn hình bằng Playwright lại là loại kiểm thử mang lại nhiều giá trị nhất cho nhóm nhỏ của tôi.","externalUrl":null,"permalink":"/vi/posts/2024/2024-06-09-playwright-tests/","section":"Bài viết","summary":"","title":"Visual testing cho các nhóm nhỏ","type":"posts"},{"content":"Đa số các nhóm đều viết thẳng các bước build và test vào tệp YAML của GitHub Actions. Cách này vẫn chạy tốt, cho đến khi bạn muốn chạy lại đúng bản build đó trên laptop của mình, hoặc có người đề xuất chuyển sang GitLab, và lúc đó bạn mới nhận ra cả pipeline đã dính chặt vào định dạng cấu hình của một nhà cung cấp duy nhất. Cách tôi chọn thì khá nhàm: toàn bộ logic thật sự nằm trong Docker Compose, còn công cụ CI chỉ có mỗi việc gọi nó, ngoài ra không làm gì thêm.\nVì sao tôi không để logic build trong YAML của Actions # Vấn đề dễ thấy nhất là bị phụ thuộc vào nhà cung cấp (vendor lock-in). GitLab, Bitbucket hay Jenkins đều không hiểu workflow của Actions, nên nếu các bước build nằm trong tệp workflow thì đổi nhà cung cấp cũng có nghĩa là viết lại từ đầu. Mà có ai lên kế hoạch chừa thời gian cho việc đó bao giờ đâu.\nVấn đề thứ hai ít ai để ý hơn: bạn không chạy được pipeline trên máy mình. Nếu muốn thử một thay đổi trong bước build mà cách duy nhất là push commit lên rồi ngồi canh tab Actions, thì lịch sử git của bạn sẽ toàn những commit kiểu \u0026ldquo;fix ci\u0026rdquo;, \u0026ldquo;fix ci again\u0026rdquo;, \u0026ldquo;please\u0026rdquo;. Còn tệp Compose thì chạy trên laptop của tôi giống hệt như trên runner, nhờ vậy phần lớn những màn cãi nhau \u0026ldquo;máy tôi chạy được mà\u0026rdquo; bị dập tắt ngay từ đầu.\nChưa hết, tệp YAML còn cứ thế phình ra. Hai mươi dòng gọn gàng ban đầu sẽ thành ba trăm dòng khi các mẹo cache và điều kiện rẽ nhánh lần lượt được thêm vào, và giờ lập trình viên nào cũng phải học thêm một \u0026ldquo;phương ngữ\u0026rdquo; CI nữa, bên cạnh Git và đủ thứ khác. Một script chỉ nên làm một việc; còn một tệp workflow cồng kềnh thì rốt cuộc sẽ ôm đồm mọi việc cùng lúc.\nVới Compose, trách nhiệm được chia rất rạch ròi: tệp Compose lo phần build và test, còn nền tảng CI chỉ đơn giản là nơi chạy nó mỗi lần có push.\nCách thiết lập # Bắt đầu với một Dockerfile, để khâu build image chỉ nằm ở đúng một chỗ: # Use an official Node.js runtime as the base image FROM node:14 # Set the working directory in the container WORKDIR /app # Copy the package.json and package-lock.json files to the container COPY package*.json ./ # Install the dependencies RUN npm install # Copy the rest of the application code to the container COPY . . # Expose the port the app runs on EXPOSE 3000 # Define the command to run the application CMD [\u0026#34;npm\u0026#34;, \u0026#34;start\u0026#34;] Sau đó tạo tệp docker-compose.build.yaml, khai báo các service cần cho việc build và test:\nservices: app: image: myprivaterepo.com/image:latest build: . volumes: - .:/app command: sh -c \u0026#34;npm install \u0026amp;\u0026amp; npm test\u0026#34; Chạy thử trên máy mình trước. Cả bài này thật ra chỉ xoay quanh bước này:\ndocker compose -f docker-compose.build.yaml build Khi bước trên đã chạy ổn, workflow của GitHub Actions (hay thứ tương đương trên bất kỳ nền tảng nào khác) chỉ còn đóng vai trò điều phối:\nname: CI/CD Pipeline on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - name: Build and Test with Docker Compose run: docker compose -f docker-compose.build.yaml build - name: Push the image referenced in the docker compose file run: docker compose -f docker-compose.build.yaml push Như vậy công cụ CI giờ chỉ làm hai việc là build và push. Mọi thứ đáng để hiểu đều nằm trong các tệp mà nền tảng nào cũng chạy trực tiếp được, và thành viên mới vào nhóm cũng vậy.\nPush image # Một cái lợi đi kèm của tệp Compose là push image trở nên cực kỳ đơn giản. Docker tự biết image sẽ được đẩy lên đâu dựa vào tên của nó: image: myprivaterepo.com/image:latest nghĩa là image sẽ được push lên myprivaterepo.com. Nếu dùng registry riêng thì bạn sẽ cần access token, phần này bạn xem thêm tài liệu của registry mình đang dùng nhé.\nNếu bạn đang dùng GitHub Actions và thấy ổn thì cũng chẳng có gì phải vội. Nhưng đến ngày cần đổi nhà cung cấp, việc chuyển đổi chỉ là chép lại hai câu lệnh, chứ không phải chuyển ba trăm dòng YAML sang định dạng mới. Còn từ giờ đến lúc đó, ít ra bạn cũng được gỡ lỗi bản build ngay trên máy mình, đàng hoàng như một người văn minh.\n","date":"02/06/2024","description":"Đưa phần build và test vào tệp Docker Compose thay vì viết trong YAML của GitHub Actions: dễ chuyển sang nền tảng khác, chạy thử được ngay trên máy mình, bảo trì cũng nhẹ đầu hơn.","externalUrl":null,"permalink":"/vi/posts/2024/2024-06-02-simplifyingcicd/","section":"Bài viết","summary":"","title":"Dùng Docker Compose cho CI/CD thay vì YAML riêng của từng nền tảng","type":"posts"},{"content":"Sau vài tháng quản lý một đội lập trình viên, tôi nhận ra phần lớn những va chạm mình phải xử lý không đến từ kỹ thuật. Vấn đề là mỗi người đang làm việc theo những giả định ngầm khác nhau: một code review nên được phản hồi trong bao lâu, \u0026ldquo;xong\u0026rdquo; thực sự nghĩa là gì, nhắn Slack cho ai đó hai lần liền thì có bị coi là bất lịch sự không. Chẳng ai sai cả. Chỉ là chúng tôi chưa bao giờ ngồi lại thống nhất luật chơi.\nCách giải quyết có một cái tên nghe khá chán: team norms, tức những quy tắc làm việc chung của đội. Center for Creative Leadership định nghĩa team norms là \u0026ldquo;một tập hợp các quy tắc hay nguyên tắc vận hành định hình cách các thành viên trong đội tương tác với nhau\u0026rdquo;. Nghe thì như giấy tờ của phòng nhân sự, nhưng thật ra chỉ là viết ra những điều mà ai cũng tưởng là hiển nhiên.\nNghiên cứu khiến tôi bị thuyết phục # Project Aristotle của Google đã nghiên cứu khoảng 180 đội trong chính công ty họ để tìm ra điều gì làm nên những đội giỏi. Câu trả lời khiến chính họ bất ngờ: trong đội có những ai không quan trọng bằng việc cả đội làm việc với nhau như thế nào. Bạn có thể dồn toàn nhân tài vào một đội mà kết quả vẫn chỉ ở mức xoàng, nếu những thỏa thuận làm việc chung lộn xộn.\nNghiên cứu chỉ ra năm yếu tố giúp các đội mạnh nổi bật hơn hẳn: an toàn tâm lý, sự đáng tin cậy, cấu trúc và sự rõ ràng, ý nghĩa, và tác động. Trong đó, an toàn tâm lý, tức là có thể hỏi một câu ngớ ngẩn hay nhận mình sai mà không bị \u0026ldquo;trả giá\u0026rdquo;, là yếu tố đóng vai trò lớn nhất. Những con số đi kèm thì khó mà cãi được: giải thích thêm 43% mức chênh lệch về hiệu suất, năng suất cao hơn 19%, đổi mới nhiều hơn 31%, tỉ lệ nghỉ việc thấp hơn 27%.\nĐiều tôi thích ở danh sách này là không yếu tố nào trong năm cái là tính cách bẩm sinh. Tất cả đều là những thứ một đội có thể chủ động xây dựng, và team norms chính là cách để xây.\nTeam norms trong công việc hằng ngày # Họp hành là chỗ dễ bắt đầu nhất, vì những thói quen xấu ở đây lộ ra quá rõ. Agenda được gửi trước, họp bắt đầu và kết thúc đúng giờ, và mọi người tóm tắt lại những gì mình vừa nghe trước khi phản bác. Tôi đặc biệt thích phiên bản thẳng thừng của quy tắc về agenda: không có agenda thì không họp.\nCác quy tắc về giao tiếp quan trọng hơn nhưng lại khó thấy hơn. Của tôi gói gọn lại thế này: mặc định cứ trao đổi nhiều hơn mức mình nghĩ là cần, ghi lại các quyết định ở chỗ ai cũng tìm được, và đừng để cuộc họp là kênh trao đổi duy nhất. Có những người sẽ không bao giờ lên tiếng trong một phòng tám người, nên hãy chủ động tìm đến hỏi thẳng họ. Có rất nhiều bằng chứng cho thấy những đội có góc nhìn đa dạng làm tốt hơn những đội đồng nhất, nhưng chỉ khi những góc nhìn đó thực sự được nói ra.\nKhi có bất đồng: hãy mặc định rằng người kia có ý tốt, tranh luận về vấn đề chứ đừng nhắm vào con người, và tìm hiểu xem mọi người thực sự cần gì, thay vì cứ bám lấy lập trường họ đã chọn. Còn nếu hiếm hoi lắm mới có một vụ tranh cãi mãi không dứt, khi đó hãy mời một bên thứ ba trung lập vào.\nĐặt ra team norms mà không biến thành thủ tục hành chính # Cách mà CCL khuyến nghị bắt đầu từ trải nghiệm thực tế chứ không phải lý thuyết, và tôi cũng sẽ làm theo cách đó. Mỗi người kể về đội tốt nhất và đội tệ nhất mà mình từng ở, cả nhóm cùng mổ xẻ xem điều gì khiến từng đội như vậy, rồi những điểm chung rút ra được sẽ trở thành đề xuất: những hành vi cụ thể cho chính đội này, được cả nhóm tranh luận và thống nhất, chứ không phải áp từ trên xuống. Sau đó, quyết định luôn từ đầu chuyện gì sẽ xảy ra khi có người phá quy tắc (kể cả khi người đó là manager), viết các quy tắc ra ở chỗ dễ thấy, và thỉnh thoảng xem lại.\nTôi chỉ muốn thêm đúng một quy tắc: bắt đầu với ba đến năm cái thôi. Team norms chỉ có tác dụng khi đã thành thói quen, mà chẳng ai tập nổi mười thói quen cùng một lúc.\nNhững chỗ dễ đổ bể # Cách nhanh nhất để giết chết team norms là người lãnh đạo phớt lờ chúng. Nếu tôi bỏ qua quy tắc về agenda chỉ vì đang bận, cả đội sẽ nhận ra ngay, và mọi thứ biến thành diễn kịch. Sự nhất quán của người đứng đầu quan trọng hơn câu chữ của bất kỳ quy tắc nào.\nVài cái bẫy khác cũng nên để ý. Người mới vào chỉ biết đến các quy tắc qua lời truyền miệng, thay vì được giải thích rõ ràng trong quá trình onboarding. Các đội làm việc xuyên quốc gia cứ nghĩ cái mặc định của mình cũng là mặc định của mọi người (hãy nói rõ ra những điều vẫn ngầm hiểu). Và có những người coi cả chuyện này là đặt ra quy trình chỉ để có quy trình. Với nhóm cuối cùng này, mời họ cùng viết các quy tắc hiệu quả hơn nhiều so với ngồi giảng giải cho họ nghe.\nCũng nên biết team norms thực sự được củng cố ở đâu. Các nghiên cứu về những đội hiệu suất cao lần nào cũng tìm ra ba thói quen giống nhau: buổi kickoff tử tế, các buổi 1:1 đều đặn, và retro. Chính ở những buổi đó, các thỏa thuận được đặt ra, được thử thách và lặng lẽ điều chỉnh lại, chứ không phải trong một văn bản chính sách nào.\nTôi không nghĩ team norms là phép màu. Nhưng dữ liệu của Google cũng chỉ về cùng một hướng với trải nghiệm của tôi cho tới giờ: cách một đội làm việc với nhau quan trọng hơn việc trong đội có những ai. Vì vậy, đội tôi giữ bộ quy tắc thật ngắn gọn. Chỉ vài thỏa thuận, viết ở chỗ ai cũng thấy, và được xem lại khi không còn đúng nữa. Sáu tháng nữa hỏi lại tôi xem chúng có trụ được không nhé.\n","date":"05/05/2024","description":"Ghi chép về team norms sau vài tháng đầu làm engineering manager, và nghiên cứu của Google đã khiến tôi tin rằng chúng quan trọng hơn cả tài năng của từng người.","externalUrl":null,"permalink":"/vi/posts/2024/2024-05-05-team-norms/","section":"Bài viết","summary":"","title":"Đặt ra team norms mà cả đội giữ được lâu dài","type":"posts"},{"content":"Từ khi nhận quản lý một đội kỹ sư hồi đầu năm, đánh giá hiệu suất với tôi đã chuyển từ chuyện người khác làm với mình thành chuyện mình phải đứng ra tổ chức. Tôi viết bài này một phần để sắp xếp lại suy nghĩ của chính mình, nên bạn hãy xem đây là ghi chép của một người đang mò đường, chứ đừng coi là chân lý.\nRốt cuộc đánh giá hiệu suất để làm gì? # Ở cấp nào thì mục đích cũng quy về hai điều: giúp mọi người tiếp tục học hỏi, và đảm bảo những ai đang làm vượt tầm vai trò của mình thực sự được ghi nhận và đền đáp xứng đáng.\nRất nhiều công ty nhỏ nói rằng họ không có thời gian hay nguồn lực để đánh giá hiệu suất, rồi lặng lẽ lấy thâm niên làm căn cứ duy nhất để xét xem ai đã sẵn sàng lên vị trí senior. Đó là một sai lầm, và không chỉ vì thâm niên là một thước đo lười biếng. Bỏ qua đánh giá tức là bỏ luôn kênh trao đổi hai chiều giữa công ty và nhân viên, và mối quan hệ làm việc sẽ vì thế mà đi xuống.\nChuyện này đúng theo cả hai chiều. Một kỹ sư có thể đang làm việc theo cách mà họ thật lòng tin là tốt nhất cho công ty, trong khi thói quen, phương pháp hay cách giao tiếp của họ lại đang dần lệch sang hướng chẳng giúp ích gì. Không có góp ý thì chẳng ai phát hiện ra. Ngược lại, công ty nào không bao giờ lắng nghe nhân viên qua một kênh bài bản thì cứ thế vô tư không biết mọi người thực sự nghĩ gì về công ty.\nMỗi bên nên kỳ vọng gì khi bước vào # Về phía nhân viên, danh sách không có gì phức tạp. Họ muốn biết công việc của mình gắn với mục tiêu của đội ra sao, mình đang làm tốt ở đâu, và cần cải thiện ở đâu, nói cụ thể chứ không phải kiểu \u0026ldquo;cần giao tiếp tốt hơn\u0026rdquo;. Họ muốn đóng góp của mình được ghi nhận, muốn có một cuộc trao đổi thực chất về lộ trình sự nghiệp và đào tạo, và một quy trình công bằng dựa trên công việc chứ không phải chuyện chính trị văn phòng.\nVề phía tôi, tôi mong mọi người mang tới một bản tự đánh giá, tức là góc nhìn của chính họ về những gì đã làm tốt và chưa tốt. Tôi mong họ cởi mở đón nhận góp ý, thay vì mở màn như luật sư bào chữa. Tôi mong có một cuộc thảo luận thật lòng về mục tiêu sắp tới, và những chia sẻ thẳng thắn về việc họ thấy mình hợp với đội đến đâu. Kỳ đánh giá cũng là lúc tôi tự lên kế hoạch cho mình: cơ cấu đội, những ai có thể được thăng chức, ai cần được phát triển theo hướng nào.\nKhông có một khuôn mẫu chung cho mọi kỹ sư # Một đội kỹ sư gồm những người khác nhau về văn hóa, kinh nghiệm và chuyên môn, nên không thể dùng cùng một kịch bản đánh giá cho tất cả. Đối xử như nhau và đối xử công bằng không phải là một. Cách tiếp cận phải linh hoạt theo nhu cầu, giai đoạn sự nghiệp và mức đóng góp của từng người. Tối thiểu, bạn phải đảm bảo vai trò và trách nhiệm của mỗi kỹ sư được định nghĩa rõ ràng và thực sự tương xứng với mức lương của họ.\nChỗ nhạy cảm nhất là chức danh và lương. Khi ai đó còn băn khoăn về chức danh của mình, hoặc thấy mình đã sẵn sàng lên cấp, thì những lời động viên chung chung chẳng khác gì thuốc độc. Cái có tác dụng là những thứ cụ thể: cùng thống nhất một số thử thách hoặc mục tiêu dự án cụ thể làm mốc cho cấp tiếp theo, để chuyện thăng tiến có tiêu chí khách quan chứ không dựa vào cảm tính. Lương cũng vậy. Nếu ai đó thấy mình đang bị trả thấp, hoặc muốn nhận thêm trách nhiệm, hãy đưa vào kế hoạch của họ những mục tiêu đo được, nói rõ cần đạt được gì trước kỳ đánh giá tới thì mới có cơ sở để điều chỉnh. Rồi đến kỳ đánh giá đó, cả hai bên phải giữ đúng lời.\nGóp ý về những điểm cần cải thiện cũng phải cụ thể như vậy. Một khóa đào tạo, một buổi workshop hay một dự án cụ thể nhắm vào đúng một điểm yếu cụ thể thì có ích. Còn một câu \u0026ldquo;tiếp tục nâng cao kỹ năng nhé\u0026rdquo; thì không.\nĐặt mốc ngay từ buổi gặp đầu tiên # Buổi đánh giá đầu tiên với một thành viên mới trong đội chủ yếu là để đặt mốc ban đầu. Hai bên cùng xem lại vai trò đó thực sự gồm những việc gì, và nó đóng góp vào mục tiêu của đội, của công ty như thế nào. Thống nhất những kết quả đo được sẽ dùng để đánh giá hiệu suất. Đặt mục tiêu cho ngắn hạn và cho xa hơn. Đây cũng là lúc thích hợp để nói về văn hóa và cách hành xử mà đội kỳ vọng, góp ý sẽ được đưa ra như thế nào và vào lúc nào, người đó có thể dựa vào những nguồn lực và sự kèm cặp nào, và lộ trình sự nghiệp từ vị trí hiện tại của họ trông ra sao. Chẳng có gì trong số này hấp dẫn cả, nhưng nhờ có buổi này mà mọi kỳ đánh giá về sau đều dễ hơn.\nAgenda tôi dùng cho các kỳ sau # Nói chuyện phiếm. Nghe thì có vẻ vặt vãnh, nhưng nó giúp không khí bớt căng, và nhờ vậy mà phần còn lại của buổi nói chuyện thành thật hơn. Điểm qua agenda để không ai bị bất ngờ vì một chủ đề nào đó. Những tiến bộ kể từ kỳ đánh giá trước: nói rõ ra họ đã tiến bộ ở đâu, vì hiếm ai tự nhận ra sự tiến bộ của mình. Hiệu suất hiện tại, bắt đầu từ điểm mạnh. Những điểm cần cải thiện, mỗi điểm kèm theo một mục tiêu cụ thể và thực tế. Kế hoạch sự nghiệp, và công việc hiện tại có thực sự đưa họ tới đó không. Chỉ tiêu, mục tiêu và những việc cụ thể cần làm trước kỳ đánh giá tới. KPI chỉ có tác dụng khi được may đo cho từng người # Với kỹ sư trong một đội product, KPI phải phù hợp với cả mục tiêu của đội lẫn từng cá nhân. Front-end, back-end và full-stack là những vai trò khác nhau, còn junior và senior thì càng khác nhau hơn nữa. Số lượng commit có thể nói lên điều gì đó về một lập trình viên junior, nhưng lại chẳng nói được gì về một kỹ sư senior mà thành quả chính là các quyết định kiến trúc. Vì thế, KPI phải được đặt ra qua trao đổi, chứ không phải copy từ một template có sẵn.\nNhững mảng tôi thường xem xét:\nChất lượng mã: tỉ lệ lỗi, mức độ tuân thủ các chuẩn chung, công việc của họ có hay phải sửa lỗi lại không Tiến độ dự án: các mốc và hạn chót đã hoàn thành đúng hẹn Đổi mới và giải quyết vấn đề: những cải thiện về hiệu quả, những bài toán khó thực sự được giải quyết Cộng tác: review cho đồng nghiệp, đóng góp vào mục tiêu và các cuộc thảo luận của đội Phát triển bản thân: các mục tiêu học tập đã đạt, chứng chỉ, công nghệ mới đã nắm được Tác động tới khách hàng: góp ý từ người dùng, mức độ người dùng sử dụng những tính năng mà họ đã phát hành Về phần đo lường được, unit test là một yếu tố mà tôi ngày càng thấy ưng. Bài viết này trên InfoQ bàn về việc đưa unit test vào đánh giá hiệu suất, cả cách làm cụ thể lẫn ảnh hưởng của nó tới văn hóa của đội, và cho rằng nên kết hợp cả thước đo định tính lẫn định lượng về độ \u0026ldquo;khỏe\u0026rdquo; của mã nguồn. Đáng đọc nếu bạn đang tìm những KPI không biến kỹ sư thành những con số đếm dòng mã.\nChọn KPI nào thì chọn, hãy cùng nhau thống nhất trong buổi đánh giá hoặc buổi 1:1, và xem lại mỗi khi vai trò, dự án hay công nghệ thay đổi. Một KPI mà không ai đồng ý thì chẳng khác gì cây roi.\nViết ra giấy # Sau buổi gặp, hãy viết một bản tóm tắt: những điểm chính đã bàn, các vấn đề được nêu ra, những việc cần làm, và những mục tiêu mà cả hai đã thống nhất. Nghe thì có vẻ quan liêu, nhưng chính bản ghi này giúp tránh được màn \u0026ldquo;ơ, đâu có thống nhất như vậy\u0026rdquo; sáu tháng sau. Vấn đề nào đã ghi lại thì sẽ được theo dõi thay vì bốc hơi, danh sách việc cần làm trở thành phần mở đầu cho agenda kỳ đánh giá sau, và người ta cũng nghiêm túc với mục tiêu hơn khi nó đã nằm trên giấy.\nToàn bộ hệ thống thật ra chỉ có vậy: kỳ vọng rõ ràng ngay từ ngày đầu, KPI may đo cho từng người, mọi thứ đều được ghi lại, và theo sát tới cùng từng việc cần làm. Một buổi đánh giá mà không phải là cuộc trao đổi hai chiều với kết quả đo được thì chỉ là làm cho có, và kỹ sư thì ngửi thấy mùi \u0026ldquo;làm cho có\u0026rdquo; ngay từ lúc nhận lời mời họp.\n","date":"27/04/2024","description":"Cách tôi tổ chức đánh giá hiệu suất cho một đội kỹ sư: mỗi bên nên kỳ vọng gì, agenda tôi hay dùng, và những KPI thực sự phù hợp với từng người.","externalUrl":null,"permalink":"/vi/posts/2024/2024-04-27-performance-reviews/","section":"Bài viết","summary":"","title":"Tổ chức đánh giá hiệu suất cho đội kỹ sư","type":"posts"},{"content":"Ở bài đầu tiên trong series này, tôi đã giải thích vì sao các đội nên thống nhất một contract OpenAPI trước khi ai bắt tay vào viết mã. Bài này nói về công cụ giúp cái contract đó thực sự phát huy tác dụng: Orval.\nTự tay viết lớp gọi API cho frontend là một trong những việc chẳng ai dám nhận là mình thích. Bạn copy type từ tài liệu của backend, viết đi viết lại cái wrapper cho request lần thứ bốn mươi, rồi ba tuần sau có ai đó đổi tên một field và thế là một nửa số chỗ gọi API của bạn âm thầm hỏng. Orval xóa sổ luôn công việc đó. Chỉ cần trỏ nó tới OpenAPI spec, Orval sẽ generate mã client cho bạn: hàm có type cho từng endpoint, model khớp chính xác với contract, và cả mock để bạn phát triển trước khi backend kịp làm xong.\nVì sao đội frontend thích Orval # Phần generate mock là phần bị đánh giá thấp nhất. Một khi spec đã chốt, frontend có thể dựng một máy chủ giả rồi làm toàn bộ giao diện dựa trên nó, thay vì ngồi chờ những endpoint lúc nào cũng \u0026ldquo;sắp xong rồi\u0026rdquo;. Khi backend thật được deploy, bạn chỉ việc trỏ client sang đó và gần như mọi thứ chạy được luôn, vì cả hai bên đều làm dựa trên cùng một tài liệu.\nCái lợi còn lại là phần mã nhàm chán giờ đúng ngay từ khi được sinh ra. Không còn ai gõ nhầm tên field trong một request body tự viết, vì đơn giản là chẳng còn ai phải tự viết request body nữa.\nVì sao đội backend cũng thích # Khi spec là nguồn sự thật duy nhất, những trao đổi hằng ngày giữa hai bên cũng thay đổi hẳn. Thay vì những thread chat kiểu \u0026ldquo;endpoint này trả về gì ấy nhỉ?\u0026rdquo;, frontend chỉ việc đọc các type đã được generate. Lập trình viên backend ít bị làm phiền hơn, mà lại nhận được góp ý sớm hơn: nếu frontend làm dựa trên mock rồi thấy API có chỗ nào khó dùng, bạn sẽ biết ngay từ lúc sửa còn rẻ, chứ không phải đợi đến khi nó đã lên production.\nOrval nằm ở đâu trong quy trình làm việc # Chẳng có gì cầu kỳ cả. Backend đề xuất spec, hai đội cùng review, frontend dùng Orval để generate client và mock rồi bắt đầu làm, còn backend thì implement các endpoint thật theo đúng contract đó. Khi spec thay đổi, cả hai bên generate lại, và compiler sẽ chỉ ra chính xác chỗ nào bị hỏng. Phần cuối đó mới là mấu chốt: những chỗ lệch nhau giữa frontend và backend sẽ hiện ra thành lỗi compile, thay vì thành lỗi trên production.\nĐây không phải loại công cụ hào nhoáng gì. Nó chỉ lặng lẽ dẹp bỏ cả một kiểu tranh cãi giữa các đội, và lần nào có cơ hội đổi như vậy, tôi cũng sẵn lòng.\n","date":"10/04/2024","description":"Orval generate API client có type và mock thẳng từ OpenAPI spec, nên bên frontend không ai phải tự tay viết lại lớp gọi API đó nữa.","externalUrl":null,"permalink":"/vi/posts/2024/2024-04-10-openapitools/","section":"Bài viết","summary":"","title":"Đừng tự viết boilerplate cho API nữa: để Orval generate giúp bạn","type":"posts"},{"content":"Có một bài viết mà tôi cứ đọc đi đọc lại, của Alan, một công ty bảo hiểm sức khỏe ở Pháp, kể về cách họ ra quyết định. Nói ngắn gọn: thay vì đặt lịch họp, họ mở một GitHub issue rồi tranh luận bằng chữ. Giờ chính tôi cũng đang quản lý một đội, nên lần này tôi ngồi ghi chép cẩn thận. Dưới đây là phần tóm lược hệ thống của họ, có xen vài nhận xét của riêng tôi.\nVì sao họ bỏ họp # Điểm xuất phát của họ chắc bạn cũng thấy quen. Không ai chuẩn bị gì, họp thì bắt đầu muộn, và ai nói to nhất thì người đó thắng, bất kể đúng hay sai. Một tiếng sau bạn bước ra còn rối hơn lúc bước vào, và bắt đầu lục ngăn kéo xem còn viên paracetamol nào không.\nBan đầu Alan thử sửa lại cách họp: tối đa 15 phút, agenda rõ ràng, họp xong bắt buộc phải có quyết định. Nhưng không duy trì được. Thảo luận cứ lan man, chẳng ai nhớ ai đã nói gì, còn người vắng mặt hay làm remote thì coi như chịu thiệt. Thế là họ chuyển hẳn sang viết.\nNhững lợi ích họ nêu ra, tôi thấy đều đúng. Viết buộc bạn phải chậm lại và suy nghĩ thật sự, nên cuộc thảo luận sâu hơn và lập luận chặt chẽ hơn. Mọi thứ được ghi lại rõ ràng, thay vì mỗi người nhớ một ít. Tranh luận không còn là cuộc thi xem ai ăn nói giỏi hơn. Và vì diễn đàn mở cho cả công ty, ai cũng có thể góp ý, chứ không chỉ những người tình cờ có mặt trong phòng họp.\nGitHub làm diễn đàn chung của công ty # Diễn đàn đó chính là GitHub, một nền tảng vốn sinh ra để code review, và Alan cứ thế vui vẻ \u0026ldquo;mượn\u0026rdquo; nó cho việc khác. Cách thiết lập đơn giản đến mức hơi ngại:\nTạo một tài khoản GitHub. Tạo một repo private không chứa dòng mã nào (repo của họ tên là \u0026ldquo;Topics\u0026rdquo;). Thêm một hệ thống label để phân loại các cuộc thảo luận. Dùng tab Issues: issue đang mở là thảo luận đang diễn ra, issue đã đóng là quyết định đã chốt. Họ đã vượt mốc 17,000 issue. Tức là có ngần ấy cuộc họp không bao giờ phải diễn ra, và, phần tôi thích nhất, họ có cả một lịch sử tra cứu được về lý do vì sao mỗi quyết định lại đi theo hướng đó. Nếu muốn lật lại một quyết định cũ, bạn có thể đọc trước xem lần trước những lập luận nào đã thắng.\nKhi nào nên mở issue # Không phải quyết định nào cũng đáng mở một issue. Họ lọc bằng bốn câu hỏi: ai sẽ bị ảnh hưởng? có dễ quay lại không? gấp đến mức nào? mình có cần thêm bối cảnh trước khi quyết không?\nĐại khái, quyết định nào tác động lớn và khó quay lại thì đưa lên diễn đàn, còn lại cứ để trên Slack. Họ dựa vào cách phân biệt \u0026ldquo;cửa một chiều\u0026rdquo; và \u0026ldquo;cửa hai chiều\u0026rdquo;. Quyết định một chiều là loại muốn quay lại thì quá tốn kém hoặc quá đau (ví dụ của họ là tăng giá), nên được xử lý đầy đủ bằng văn bản. Quyết định hai chiều thì dễ quay lại, tệ nhất cũng chỉ mất chút công sức, nên không cần thủ tục rườm rà.\nLOCI: ai làm gì # Mỗi cuộc thảo luận đều phân vai theo mô hình LOCI: Lead, Owner, Consulted, Informed.\nLead chịu trách nhiệm về thành công của quyết định và đảm bảo cả đội hiểu rõ bối cảnh. Ngay từ đầu, Lead nói rõ mình muốn tham gia sâu đến đâu, sau đó tin tưởng giao cho Owner, chịu trách nhiệm về những gì đội làm ra, và chỉ lên tiếng khi thực sự không đồng ý. Mỗi thảo luận chỉ có đúng một Lead.\nOwner là người chốt quyết định cuối cùng và kéo dự án đi tới. Owner giữ cho những người tham gia luôn nắm được tình hình và góp ý đúng hạn, tìm hiểu vấn đề, mở và đóng issue, rồi thông báo quyết định. Nếu vướng một bức tường không tự vượt qua được thì Owner tìm đến Lead. Mỗi quyết định chỉ có đúng một Owner.\nConsulted là những người mà ý kiến của họ thực sự ảnh hưởng tới việc cân nhắc được mất. Họ phải góp ý kịp thời, dù chỉ để nói \u0026ldquo;tôi không có gì thêm\u0026rdquo;, và phải lên tiếng cảnh báo (đẩy lên Lead nếu cần) khi thấy quyết định đang sai nghiêm trọng. Alan giới hạn nhóm này khoảng sáu người, vì càng thêm người góp ý thì càng tốn thêm thời gian.\nInformed thì đơn giản là những người được báo tin, thường là sau khi quyết định đã chốt. Đây là giao tiếp một chiều, không ai chờ họ phản hồi. Owner là người quyết định ai bị ảnh hưởng đủ nhiều để cần được thông báo.\nMột issue gồm những gì # Issue nào cũng dùng chung một template. Template này vừa giúp suy nghĩ rõ ràng hơn, vừa ngầm dạy các bạn junior cách bóc tách một vấn đề:\nScope: lý do có cuộc thảo luận này, kèm một dòng hay bị xem nhẹ là \u0026ldquo;issue này KHÔNG bàn về…\u0026rdquo; Context \u0026amp; materials: link tới các thảo luận và tài liệu trước đó LOCI: ai là Lead, Owner, Consulted, Informed Hạn chót: góp ý trước khi nào, khi nào đóng issue, khi nào cần có giải pháp Đề xuất giải pháp: một phương án cụ thể, hoặc vài lựa chọn để cân nhắc Câu hỏi: hỏi thẳng từng người cụ thể để xác nhận hoặc bác bỏ từng giả định cụ thể, để issue không thành lá thư bỏ vào chai thả trôi Quy tắc viết để mọi thứ không bị ì # Tranh luận bằng chữ cũng có thể lê thê y như tranh luận bằng miệng, nên họ đặt ra quy tắc. Viết ngắn gọn. Họ trích lời Boileau: \u0026ldquo;Điều gì đã nghĩ thấu thì sẽ nói ra rõ ràng.\u0026rdquo; Nếu bạn không viết được nó ra một cách đơn giản, tức là bạn vẫn chưa thực sự chắc. Mọi thứ viết ra đều phải qua phép thử \u0026ldquo;Thì sao?\u0026rdquo;: nếu bình luận của bạn dẫn tới hệ quả gì mà người đọc chưa thấy ngay, hãy nói thẳng ra.\nVới Owner, việc theo sát về sau quan trọng không kém lúc mở đầu. Phải giữ cho cuộc thảo luận bám vào \u0026ldquo;câu hỏi triệu đô\u0026rdquo;, tức đúng cái điểm vướng thực sự quan trọng, thay vì để nó tan vào tiểu tiết. Thường xuyên tóm tắt lại những gì đã thống nhất và những gì còn bỏ ngỏ. Còn nhắc nhở qua Slack thì chỉ là phương án cuối cùng, dành cho những phần góp ý đã quá hạn hoặc chuyện thực sự gấp.\nOwner cũng ẩn đi những bình luận không giúp cuộc thảo luận tiến lên. Ai cũng phải thạo cách định dạng trên GitHub, từ heading, footnote, bảng cho tới phím tắt, vì khi viết thì hình thức gần như quan trọng ngang nội dung.\nKhông cần đồng thuận, không có khán giả # Có hai quy tắc giữ cho cách làm này không biến thành kiểu quyết định theo hội đồng.\nThứ nhất, Owner ra quyết định theo kiểu mà Alan gọi là \u0026ldquo;nhà độc tài sáng suốt\u0026rdquo;. Họ tìm hiểu các ý tưởng, cân nhắc rủi ro, chất vấn đồng nghiệp, nhưng không đi tìm sự đồng thuận và cũng không bắt buộc phải làm theo mọi điều người khác nói. Alan nói thẳng họ không phải một nền dân chủ: nhân viên nào cũng là cổ đông, và lợi ích của công ty đặt trên lợi ích của một đội hay một cá nhân. Khi Owner đã khá chắc về một hướng đi, họ quyết, mọi người cùng làm thử, và khi đã thấy được tác động thì cùng nhìn lại xem quyết định đó đúng hay sai.\nThứ hai, số người tham gia được kiểm soát. Quy tắc \u0026ldquo;hai chiếc pizza\u0026rdquo; của Jeff Bezos cũng đúng với cách làm việc async: issue càng đông người thì mỗi người càng góp ít. Quá khoảng sáu người là hiệu ứng khán giả xuất hiện, và chất lượng góp ý đi xuống. Chuyện thông báo cũng theo cùng tinh thần đó. Alan tin rằng ai cũng đã tự cấu hình thông báo GitHub cho mình (vào Settings, rồi tick \u0026ldquo;Web and Mobile\u0026rdquo; ở cả \u0026ldquo;Participating\u0026rdquo; lẫn \u0026ldquo;Watching\u0026rdquo;), nên việc tag thẳng tên người trong phần Câu hỏi được giữ ở mức tối thiểu.\nNhững gì tôi học được # Bạn không bao giờ kể lại được trọn vẹn một cuộc họp cho người vắng mặt, nhưng một quyết định đã viết ra thì ai cũng đọc được. Alan không nói đây là hệ thống tốt nhất hay duy nhất, chỉ là hệ thống hợp với họ: ít bị ngắt quãng hơn, ít hiệu ứng khán giả hơn, và quyết định được đưa ra sau khi đã lùi lại một bước để nhìn, với đầy đủ dữ kiện trên giấy. Tôi chưa định chuyển mọi quyết định của đội mình sang GitHub issue, nhưng cái template dưới đây thì sẽ vào thẳng repo của chúng tôi.\nTemplate đề xuất # Scope # Mục tiêu của issue này là \u0026hellip; Issue này KHÔNG bàn về \u0026hellip; Vì sao tôi mở issue này # Timeline # Bối cảnh và tài liệu # Đề xuất # Câu hỏi? # ","date":"09/04/2024","description":"Alan thay gần hết các cuộc họp bằng GitHub issue viết thành văn. Đây là ghi chép của tôi về cách hệ thống đó vận hành, và những gì tôi muốn học theo.","externalUrl":null,"permalink":"/vi/posts/2024/2024-01-04-githubforprojectmanagement/","section":"Bài viết","summary":"","title":"Ra quyết định mà không cần họp: cách Alan dùng GitHub issue","type":"posts"},{"content":"Sản phẩm SaaS B2B nào sớm muộn cũng phải đối mặt với câu hỏi về tenancy, thường là vào cái ngày có một khách hàng lớn tiềm năng hỏi xem dữ liệu của họ có được lưu tách riêng khỏi các khách hàng khác không. Tôi đã chứng kiến cuộc thảo luận này ở hơn một công ty, và lần nào câu trả lời cũng quy về hai lựa chọn: cho mỗi khách hàng một instance riêng (single-tenant, multi-instance), hoặc cho tất cả chạy chung trên một instance (multi-tenant, single-instance). Không có lựa chọn nào là \u0026ldquo;đúng\u0026rdquo;. Mỗi cái chỉ gặp sự cố theo một kiểu khác nhau.\nThiết kế hạ tầng # Single tenant, multi-instance # Single Tenant, Multi-Instance: Với mô hình này, hạ tầng được nhân thành nhiều instance, mỗi tenant chạy trên một instance riêng. Điểm hấp dẫn nhất ở đây là tính cô lập, và gần như mọi lợi ích khác đều xuất phát từ đó. Dữ liệu của mỗi khách hàng nằm trong một môi trường riêng, nên các đợt đánh giá bảo mật hay những buổi làm việc về compliance dễ thở hơn hẳn. Một lần deploy lỗi hay một workload quá nặng chỉ làm sập một khách hàng, chứ không kéo sập tất cả. Bạn có thể scale, tinh chỉnh và tùy biến từng instance một cách độc lập, và vì tài nguyên được cấp riêng nên hiệu năng của khách này không phụ thuộc vào việc tenant bên cạnh đang làm gì. Lịch bảo trì cũng có thể xếp theo từng khách hàng, nên thời gian downtime vừa ngắn vừa đúng chỗ. Và nếu định giá hợp lý, bạn có thể tính chi phí hạ tầng sát với mức sử dụng thực tế của từng tenant.\nCái giá phải trả là giờ bạn đang vận hành N bản sao của mọi thứ. Chi phí tài nguyên cộng dồn lại, cấu hình giữa các instance dần dần lệch nhau, và riêng việc giữ cho phiên bản lẫn dữ liệu đồng bộ trên cả dàn instance đã thành một dự án riêng. Chuyện scale, cô lập và quản lý chi phí vẫn cần được lên kế hoạch nghiêm túc. Các vấn đề không hề biến mất, chúng chỉ nhân lên.\nMulti tenant, single instance # Multi Tenant, Single Instance: Chỉ một ứng dụng duy nhất phục vụ tất cả các tenant. Đây là lựa chọn mặc định cũng có lý do của nó: một lần deploy, một cơ sở dữ liệu cần backup, một phiên bản duy nhất trên production. Mô hình này tiết kiệm tài nguyên, vận hành đơn giản, và mọi khách hàng đều nhận bản cập nhật cũng như bản vá bảo mật cùng một lúc.\nNhững phần khó lại chính là mặt trái của việc dùng chung. Chỉ một tenant dùng nặng cũng có thể kéo hiệu năng của tất cả mọi người đi xuống. Khả năng tùy biến cho từng khách hàng thì bị hạn chế. Việc cô lập dữ liệu giờ nằm trong mã ứng dụng chứ không còn nằm ở tầng hạ tầng, nên chỉ một lỗi nhỏ cũng có thể dẫn tới rò rỉ dữ liệu giữa các tenant. Khi gặp bottleneck về scale thì mọi khách hàng cùng chịu ảnh hưởng, và việc bảo trì phải cẩn thận hơn, vì không còn kiểu sự cố \u0026ldquo;chỉ ảnh hưởng mỗi khách này\u0026rdquo; nữa.\nTìm hiểu thêm về giải pháp SaaS - Kiến trúc Multi-instance và Multi-tenant\nNếu chọn single-tenant thì bắt đầu từ đâu? # Nếu bạn quyết định đi theo single-tenant, công cụ sẽ quyết định mọi thứ còn nằm trong tầm kiểm soát, hay trở thành một thảm họa diễn ra từ từ. Không ai quản lý thủ công nổi ba mươi môi trường cả.\nPhần cốt lõi là container kết hợp với infrastructure as code. Docker đóng gói ứng dụng giống hệt nhau cho mọi tenant, Kubernetes lo việc deploy và scale cả đống instance đó, còn Terraform, Pulumi hay AWS CloudFormation cho phép bạn định nghĩa toàn bộ stack của một tenant bằng mã. Nhờ vậy, dựng thêm khách hàng thứ 31 chỉ tốn một pull request, chứ không phải mất trắng một cuối tuần. Nếu hệ thống của bạn thiên về VM, các công cụ quản lý cấu hình như Ansible, Chef hay Puppet cũng đóng vai trò tương tự. Ở mô hình này, một pipeline CI/CD vững chắc (Jenkins, GitLab CI/CD) còn quan trọng hơn bình thường, vì mỗi bản phát hành phải được rollout tới mọi instance mà không cần ai ngồi canh.\nNgoài những thứ đó ra thì tùy vào đặc thù sản phẩm của bạn. Serverless (AWS Lambda, Azure Functions) có thể loại bỏ hẳn tầng máy chủ cho một số workload. Các dịch vụ cơ sở dữ liệu managed như Amazon RDS hay Azure SQL Database giúp bạn khỏi phải tự tay quản trị từng cơ sở dữ liệu cho từng tenant. Giám sát bằng Prometheus, Grafana hay New Relic thì không còn là tùy chọn khi bạn đã có cả một dàn instance, vì bạn muốn biết instance nào đang trục trặc trước khi khách hàng gọi tới báo. Về định danh, một dịch vụ như Auth0 hay Okta sẽ lo phần quản lý người dùng và phân quyền truy cập, để bạn khỏi phải làm lại cho từng tenant. Dù bạn dùng ngôn ngữ nào thì chắc cũng có sẵn framework hay library hướng SaaS giúp bạn bớt được phần nào đống việc \u0026ldquo;đi dây\u0026rdquo; này, và cloud lớn nào (AWS, Azure, Google Cloud Platform) cũng sẵn lòng host hết cho bạn.\nĐừng dùng hết cả danh sách này ngay từ ngày đầu. Hãy bắt đầu với container và IaC, bổ sung giám sát và IAM khi số tenant tăng dần, rồi để những khó khăn thực tế quyết định phần còn lại.\n","date":"06/04/2024","description":"Ghi chép về việc lựa chọn giữa cấp cho mỗi khách hàng một instance riêng và cho tất cả dùng chung hạ tầng, cùng những công cụ giúp bạn trụ được nếu đi theo hướng single-tenant.","externalUrl":null,"permalink":"/vi/posts/2024/2024-04-07-singletenantsingleinstance/","section":"Bài viết","summary":"","title":"SaaS single-tenant hay multi-tenant: khi nào nên chọn cái nào","type":"posts"},{"content":"Mọi chuyện bắt đầu lúc 8:50 sáng. Cả nhóm tập trung trước văn phòng để chờ chiếc xe van riêng tới đón. Điểm dừng đầu tiên là một quán cà phê, nơi chúng tôi làm một buổi workshop để hiểu hơn về những giá trị mà mỗi người coi trọng, và cùng nhau đặt ra team norms, tức những quy tắc làm việc chung của đội.\nTrên xe, tôi ngồi cạnh chị chief of staff, và hai người nói chuyện về việc tìm cho mình một sở thích. Cả hai đều thấy rằng với nhịp sống bận rộn bây giờ, khó mà tìm được một sở thích để tự mình tận hưởng. Từ đó câu chuyện chuyển sang thời đại học. Thú vị là chị ấy từng là nghệ sĩ và rất mê làm nghệ thuật 3D. Nhưng theo lời chị kể, cái áp lực lúc nào cũng phải sáng tác có vẻ không hợp với nếp sống của chị. Thế là tôi đùa rằng chị nên làm một tác phẩm cho cái văn phòng hơi bị vô hồn của chúng tôi. Haha\nTới quán, chúng tôi tìm được ngay một cái bàn đủ rộng cho cả nhóm. Được ngồi cùng nhau nói về những điều thực sự quan trọng với mỗi người, vui lắm.\nMọi người đều rất tôn trọng ý kiến của nhau, còn anh CEO và người điều phối thì khéo léo dẫn dắt buổi workshop để cả nhóm kịp giờ cho hoạt động tiếp theo.\nHoạt động tiếp theo là ghé thăm một nhà chưng cất rượu tên là ‘Three Societies’! Tôi vốn mê whisky, nên đây đúng là dịp hoàn hảo để khoe chút hiểu biết (có phần hơn mức trung bình) của mình về cách làm whisky và cách thưởng thức nó. Anh hướng dẫn viên nhận luôn thử thách giới thiệu cả nhà chưng cất bằng hai thứ tiếng Anh và Hàn. Đầu tiên, chúng tôi xem công đoạn cho lúa mì vào thùng lớn rồi ngâm để chiết lấy đường. Sau đó là xem mẻ rượu đã lên men được chừng vài ngày. Cuối cùng là nồi chưng cất, trông giống như một viên kẹo Hershey’s Kiss khổng lồ. Bên trong nóng khoảng 30 độ, nên khi hướng dẫn viên bảo cả nhóm ra ngoài xem các kho chứa, ai cũng thấy nhẹ cả người.\nTrong kho, các thùng rượu làm từ đủ loại gỗ được xếp chồng lên tận mái. Một số thùng có dán nhãn Gochujang (tương ớt Hàn Quốc) ở đầu thùng, một kiểu kết hợp mang hơi hướng Hàn Quốc. Chúng tôi còn được thử whisky rót thẳng từ thùng, nồng độ khoảng 60%! Cổ họng ai cũng như bốc cháy!\nĐể kết thúc buổi tham quan, chúng tôi được nếm các sản phẩm hoàn chỉnh của họ, gồm những loại whisky ủ trong các thùng có tuổi khác nhau. Chúng tôi còn được thử cả gin, mà theo tôi thì chọn món này là hơi xui, vì nó có nhiều hương vị na ná mùi nấm.\nCuối ngày, cả nhóm lên xe về lại Seoul, ghé Social Dive làm vài ly nhẹ nhàng rồi mới ai về nhà nấy. Một ngày mà làm được bao nhiêu thứ, và tôi nghĩ qua những lúc cùng nhau vui chơi như vậy, chúng tôi đã hiểu nhau hơn nhiều!\nNếu bạn đang tìm chỗ làm ở Hàn Quốc, cứ liên hệ với tôi nhé!\n","date":"03/03/2024","description":"Một ngày đi chơi cùng đội product của DNK: workshop về giá trị ở quán cà phê, tham quan nhà chưng cất Three Societies, rồi làm vài ly nhẹ nhàng khi về lại Seoul.","externalUrl":null,"permalink":"/vi/posts/2024/2024-03-03-dnk-outing/","section":"Bài viết","summary":"","title":"Chuyến đi chơi của đội Product ở DNK!","type":"posts"},{"content":"Tháng này tôi bắt đầu quản lý một đội kỹ sư. Có một điều không ai nói trước với tôi: việc gì cũng có vẻ gấp. Đội mới, công ty mới, và một danh sách việc cần làm đã có từ lâu trước khi tôi tới. Tôi không thể làm hết cùng một lúc, nên tôi chọn ra ba việc phải làm cho đúng trước khi đụng tới bất cứ thứ gì khác.\nLàm quen với đội # Tôi đang đặt lịch 1:1 với từng người, và cố giữ cho các buổi này xoay quanh con người, chứ không phải chuyện sprint. Họ muốn gì cho sự nghiệp của mình? Dạo này điều gì làm họ bực mình? Nếu được, ngày mai họ muốn thay đổi gì? Trong mấy buổi này, việc của tôi chủ yếu là im lặng và lắng nghe. Niềm tin không thể xây xong trong tuần đầu tiên, nhưng chắc chắn có thể mất ngay trong tuần đầu tiên.\nMỗi người lại làm việc theo một kiểu rất khác nhau. Có người muốn được hướng dẫn chi tiết, có người thì làm tốt nhất khi bạn để họ yên, và nếu đối xử với người này theo cách dành cho người kia thì chẳng ai vui cả. Cuốn \u0026ldquo;The Manager\u0026rsquo;s Path\u0026rdquo; của Camille Fournier viết về chuyện này rất hay. Hóa ra sự đồng cảm và thật lòng lắng nghe mới là phần lớn công việc.\nHiểu vì sao mọi thứ lại như bây giờ # Mỗi quy trình, mỗi chỗ kỳ quặc trong kiến trúc đều là dấu vết của một quyết định nào đó trong quá khứ. Trước khi thay đổi gì, tôi muốn biết người ta đã thử những gì, cái gì đã thất bại, và công ty thật sự quan tâm đến điều gì.\nCông ty nào cũng có những luật bất thành văn về cách các quyết định thực sự được đưa ra. Tôi nắm được chúng càng nhanh thì càng làm được nhiều việc cho đội. Andrew Grove có nói ý này trong cuốn \u0026ldquo;High Output Management\u0026rdquo;: bạn không thể gắn công việc của đội với bức tranh lớn khi chính mình còn chưa hiểu bức tranh đó.\nĐọc mã nguồn # Tôi không cần phải là người lập trình giỏi nhất đội nữa, nhưng tôi cần hiểu kiến trúc, những chỗ đang gây khó chịu, và technical debt đang bị chôn ở đâu. Vì vậy tôi đọc mã, mày mò cái deployment pipeline, và hỏi mọi người rằng nếu có một tuần rảnh thì họ sẽ sửa gì. Câu trả lời cho câu hỏi cuối cùng này thường là cái roadmap thành thật nhất mà bạn có được.\nLàm vậy cũng là cách để đội thấy rằng tôi quan tâm đến chính công việc họ làm, chứ không chỉ đến mấy quy trình bọc quanh nó.\nRồi thì đứng sang một bên # Khi đã có nền móng đó, kế hoạch rất đơn giản: cung cấp bối cảnh, chia sẻ mục tiêu, và để đội tự đưa ra các quyết định kỹ thuật. Việc của tôi không phải là duyệt từng PR, mà là lo cho mọi người có đủ những gì họ cần và đang đi đúng hướng. Cuốn \u0026ldquo;The Phoenix Project\u0026rdquo; của Gene Kim cũng lập luận y như vậy: những đội được tự quyết thì đi nhanh hơn và làm ra phần mềm tốt hơn những đội lúc nào cũng phải chờ cấp trên gật đầu.\nSáu tháng nữa hỏi lại tôi xem mọi chuyện thế nào nhé.\n","date":"10/02/2024","description":"Tôi vừa bắt đầu quản lý một đội kỹ sư, và đây là ba việc tôi muốn làm cho đúng trước tiên: con người, bối cảnh công ty, và mã nguồn.","externalUrl":null,"permalink":"/vi/posts/2024/2024-02-10-prioritizing-onboarding/","section":"Bài viết","summary":"","title":"Ba điều tôi ưu tiên khi mới làm engineering manager","type":"posts"},{"content":"Có một tin mà tới giờ nghĩ lại tôi vẫn còn cười toe: đội chúng tôi đã thắng Seoul Tech Impact Hackathon 2023. Sản phẩm của chúng tôi là Pocha, một ứng dụng định vị giúp bạn tìm các quầy đồ ăn đường phố ở Hàn Quốc.\nÝ tưởng # Quầy đồ ăn đường phố ở Hàn Quốc không có địa chỉ. Một quán pojangmacha (kiểu lều bán đồ ăn, đồ nhậu ven đường) dựng ở đâu thì bán ở đó, và nếu bạn chưa biết chỗ từ trước thì có lên mạng tìm cũng chịu. Pocha đưa các quầy này lên bản đồ, theo đúng nghĩa đen. Vậy là vừa có lợi cho người bán, vừa có lợi cho bất kỳ ai đang đi lùng một đĩa tteokbokki lúc nửa đêm.\nCuối tuần hôm đó # Hackathon do Lunit Global tổ chức, và họ cho mượn luôn văn phòng để làm địa điểm. Các đội đến từ đủ mọi lĩnh vực, phần lớn chúng tôi thức trắng làm xuyên đêm, còn ban tổ chức thì lo cho mọi thứ chạy trơn tru. Đúng kiểu sự kiện mà bạn ngẩng đầu lên thì đã 4 giờ sáng lúc nào không hay.\nĐội của chúng tôi # Đội gồm Sharon Levy, Jonothan Sue, tôi và Tiaan van der Riel. Mỗi người có một thế mạnh riêng, nên chia việc chẳng mấy khó khăn. Tôi nghĩ bài pitch thuyết phục được ban giám khảo là vì vấn đề này không cần giải thích nhiều. Chỉ cần nói ra câu \u0026ldquo;các quầy đồ ăn đường phố đang vô hình trên mạng\u0026rdquo; là giải pháp tự hiện ra.\nBước tiếp theo # Chúng tôi đang chuẩn bị đưa Pocha lên các kho ứng dụng. Pocha khởi đầu chỉ là một dự án làm cho vui trong một cuối tuần, nhưng vấn đề nó giải quyết là có thật, nên giờ mà dừng thì thấy phí.\nNếu bạn chưa đi hackathon bao giờ, tôi khuyên bạn nên thử một lần. Bạn cùng những người chưa từng làm chung dựng lên một sản phẩm thật nhanh, biết được mình thực sự chịu áp lực ra sao, và thỉnh thoảng bạn ra về với một thứ đáng để làm tiếp. Tệ nhất thì bạn cũng được một cuối tuần ăn vặt miễn phí, cộng thêm một cái prototype chạy được mà chẳng ai nhờ làm.\nLink: # https://lnkd.in/drMXUgPB\n","date":"05/01/2024","description":"Đội chúng tôi làm Pocha, một ứng dụng định vị giúp tìm các quầy đồ ăn đường phố ở Hàn Quốc, và đã giành chiến thắng tại Seoul Tech Impact Hackathon 2023.","externalUrl":null,"permalink":"/vi/posts/2024/2024-01-05-hackathon/","section":"Bài viết","summary":"","title":"Chúng tôi thắng Seoul Tech Impact Hackathon 2023","type":"posts"},{"content":"Đây là đổi nghề, chứ không phải thăng chức\nTôi cũng sắp bước sang vai trò này, nên thời gian gần đây tôi đi hỏi chuyện những người đã từng chuyển, và tìm được gì thì đọc nấy. Bài học rõ nhất cho tới giờ là: từ kỹ sư lên engineering manager không phải là leo thêm một bậc. Thực ra bạn đang bước ngang sang một công việc khác hẳn. Thành quả của bạn không còn là những dòng mã mình viết ra, mà là những gì cả đội làm ra. Ai cũng nói vậy, và theo tôi thấy thì ai cũng vẫn xem nhẹ chuyện này.\nNhững gì thực sự thay đổi\nBạn không còn trực tiếp giải các bài toán kỹ thuật nữa, mà chuyển sang giải các bài toán về con người. Lịch làm việc kín mít những buổi 1:1, họp lên kế hoạch, họp để các đội thống nhất với nhau. Chuyện tuyển dụng và đánh giá hiệu suất giờ đổ lên đầu bạn. Phần lớn công việc là lo cho đội có đủ những gì họ cần để yên tâm làm việc: đủ bối cảnh, thứ tự ưu tiên rõ ràng, và càng ít chuyện vớ vẩn càng tốt.\nCái khó là bạn vẫn phải giữ đủ trình độ kỹ thuật để không bị lép vế khi bàn về kiến trúc và để đưa ra những quyết định hợp lý, trong khi lại phải chấp nhận rằng mình không còn là người trực tiếp viết mã. Những manager tôi từng thấy cố ôm cả hai việc thường làm dở cả hai.\nNhững kỹ năng có vẻ quan trọng\nGiao tiếp chiếm gần hết công việc. Bạn trở thành người \u0026ldquo;phiên dịch\u0026rdquo; giữa đội mình, bên product và ban lãnh đạo, và một phần lớn đến bất ngờ của công việc chỉ là đảm bảo ba bên đang nói về cùng một chuyện. Niềm tin quan trọng hơn mọi quy trình: nếu mọi người không thấy yên tâm khi báo với bạn chỗ nào đang hỏng, bạn sẽ là người biết chuyện sau cùng. Lịch làm việc thì sẽ tìm mọi cách nuốt chửng bạn, nên giữ lại thời gian để suy nghĩ, và thời gian cho đội, là một cuộc chiến không có hồi kết. Cuối cùng, bạn phải hiểu công ty thực sự đang muốn đạt được điều gì, không thì bạn chẳng thể chỉ cho ai một hướng đi có ích.\nChuẩn bị từ trước khi có chức danh\nLời khuyên tôi nghe đi nghe lại, và cũng là điều tôi đã tự kiểm chứng được: hãy bắt đầu dẫn dắt trước khi có ai trao chức danh cho bạn. Đứng ra lo một dự án, kèm cặp một đồng nghiệp, hay tổ chức một việc gì đó. Suốt năm qua tôi làm project lead, và trải nghiệm đó giúp tôi nhiều hơn bất kỳ cuốn sách nào. Dù vậy, sách vẫn có ích. Cuốn \u0026ldquo;The Manager\u0026rsquo;s Path\u0026rdquo; của Camille Fournier thì ai cũng giới thiệu, và đọc xong tôi hiểu vì sao. Đừng để kỹ năng kỹ thuật bị cùn, vì các cuộc tranh luận về kiến trúc sẽ không dừng lại đâu. Và hãy tìm một mentor đã từng đi qua con đường này, họ sẽ giúp bạn tránh những sai lầm mà chỉ khi nhìn lại mới thấy hiển nhiên.\nĐược cái này, mất cái kia\nBạn có nhiều tiếng nói hơn trong chuyện sản phẩm được làm ra như thế nào, nhưng lại hiếm khi tự tay làm. Tôi đoán sẽ có những ngày thấy chuyện đó thật tuyệt, và cũng có những ngày nhớ viết mã đến phát điên. Ai từng chuyển sang vai trò này cũng bảo tôi đó là chuyện bình thường. Sáu tháng nữa hỏi lại tôi nhé.\n","date":"02/01/2024","description":"Khi bạn thôi viết mã và chuyển sang quản lý chính những người viết mã, điều gì thực sự thay đổi?","externalUrl":null,"permalink":"/vi/posts/2024/2024-01-02-engineeringmanager/","section":"Bài viết","summary":"","title":"Từ kỹ sư chuyển sang làm engineering manager","type":"posts"},{"content":"Thỉnh thoảng trong buổi sprint planning lại có một story mà không ai chấm point nổi. Người chấm 2, người chấm 13, rồi có ai đó buông một câu \u0026ldquo;ừ thì, còn tùy\u0026rdquo;, và cuộc thảo luận cứ thế đi vòng vòng. Nên dùng GraphQL hay REST? Hệ thống có thật sự chịu được 10,000 người dùng cùng lúc không? Thư viện bên thứ ba này đã đủ ổn định để đưa lên production chưa? Với những câu hỏi kiểu này, ngồi ước lượng bao lâu cũng không ra. Bạn phải bắt tay vào tìm hiểu, và spike sinh ra chính là để làm việc đó.\nSpike từ đâu mà ra # Thuật ngữ này bắt nguồn từ Extreme Programming (XP). Ở đó, spike là một \u0026ldquo;chương trình rất đơn giản để khám phá các giải pháp tiềm năng\u0026rdquo;, giống như đóng một cây đinh (spike) xuyên thẳng qua vấn đề. Ngày nay, spike là một nhiệm vụ nghiên cứu ngắn, có time-box, và đầu ra của nó là kiến thức chứ không phải phần mềm chạy được. Spike không phải user story, tự nó cũng không mang lại giá trị gì cho khách hàng, và như vậy cũng không sao. Nhiệm vụ của nó là trả lời một câu hỏi cụ thể, để phần việc thật sau đó được ước lượng và lên kế hoạch cho sát thực tế.\nTrên thực tế có hai loại spike. Spike kỹ thuật trả lời câu hỏi \u0026ldquo;làm thế nào\u0026rdquo;: đánh giá một framework hay thư viện, làm prototype cho một pattern kiến trúc, kiểm thử hiệu năng trong điều kiện sát với thực tế, hoặc tìm hiểu xem một bài toán tích hợp thật ra sẽ vất vả đến mức nào. Spike chức năng thì trả lời câu hỏi \u0026ldquo;làm cái gì\u0026rdquo;: làm rõ một story còn mơ hồ, thử một ý tưởng giao diện bằng prototype làm xong là bỏ, hoặc mày mò nghiệp vụ phức tạp cho đến khi hiểu ra vấn đề.\nVì sao đáng bỏ thời gian # Cố ước lượng một việc còn nhiều ẩn số lớn thì kết quả thường chỉ có hai kiểu: hoặc ước lượng bị độn lên cho chắc, hoặc trễ hạn chót. Spike biến \u0026ldquo;chẳng biết gì\u0026rdquo; thành dữ liệu thật, để cả nhóm có thể cam kết mà không phải chột dạ. Nó cũng thay những cuộc tranh cãi cảm tính bằng bằng chứng: thay vì cãi nhau xem cơ sở dữ liệu nào tốt hơn, bạn bỏ ra hai ngày để đo, rồi đưa kết quả thẳng vào tài liệu quyết định của mình.\nNhưng trên hết, spike là một khoản bảo hiểm rẻ. Bỏ hai ngày làm spike để phát hiện ra giới hạn của một thư viện thì tốn ít hơn nhiều so với việc phát hiện ra đúng giới hạn đó khi đã phát triển được ba sprint.\nKhi nào nên (và không nên) dùng spike # Hãy dùng spike khi nhóm không tự tin ước lượng một story vì còn những ẩn số kỹ thuật, khi có nhiều phương án khả thi và bạn cần dữ liệu để chọn, khi bạn đang cân nhắc một công nghệ hay một bài toán tích hợp mới, khi hiệu năng vừa chưa chắc chắn vừa cực kỳ quan trọng, hoặc khi yêu cầu vẫn mơ hồ dù bạn đã ngồi họp với stakeholder không biết bao nhiêu lần.\nNgược lại, đừng dùng spike cho những việc nhóm đã biết cách làm, hay để trì hoãn một quyết định mà bạn hoàn toàn có thể đưa ra với thông tin đang có. Spike cũng không thay thế được việc thu thập yêu cầu cẩn thận hay kiểm thử chấp nhận người dùng: nó trả lời một câu hỏi, chứ không kiểm chứng một sản phẩm.\nTôi làm một spike như thế nào # Đầu tiên là viết câu hỏi ra. Không phải \u0026ldquo;nghiên cứu về cache\u0026rdquo; mà là \u0026ldquo;Redis cluster có đáp ứng được yêu cầu độ trễ 50ms của mình không?\u0026rdquo;. Xác định phạm vi rõ ràng, cái gì nằm trong, cái gì nằm ngoài; đặt time-box từ một đến ba ngày (nếu cần lâu hơn thì câu hỏi đang quá rộng); và đặt tiêu chí thành công để biết khi nào thì xong.\nSau đó bắt tay vào tìm hiểu: đọc tài liệu và các case study, dựng một proof of concept tối giản, hỏi chuyện những người từng làm rồi, và đo đạc bằng mã thật. Mã viết trong spike vốn dĩ là để vứt đi.\nKhi hết time-box, hãy viết lại những gì tìm được, một đề xuất kèm lý do, và mọi rủi ro bạn phát hiện ra, cùng đường link tới prototype được ghi rõ là sản phẩm của spike. Trình bày cho cả nhóm, trả lời các câu hỏi, rồi điều chỉnh backlog: tạo story mới, chỉnh sửa, hoặc bỏ hẳn story dựa trên những gì đã học được. Cũng nên dành chút thời gian nhìn lại xem bản thân spike đó đã diễn ra thế nào trong buổi retrospective tiếp theo; làm spike càng nhiều thì nhóm càng làm tốt hơn thấy rõ.\nMẫu tài liệu tôi dùng # ## Spike: [Title] **Time-box**: [X days] **Owner**: [Name] **Sprint**: [Sprint number/name] ### Question to Answer [Single, focused question this spike will answer] ### Background [Why this spike is needed; what triggered the uncertainty] ### Assumptions - [Assumption 1] - [Assumption 2] ### Scope **In Scope**: - [Item 1] - [Item 2] **Out of Scope**: - [Item 1] ### Success Criteria - [ ] [Criterion 1] - [ ] [Criterion 2] ### Findings [To be completed during spike] ### Recommendation [To be completed after spike] ### Follow-up Stories - [ ] [Story 1] - [ ] [Story 2] Phần Assumptions (các giả định) rất đáng có. Trước khi bắt đầu, nếu bạn viết ra những giả định như \u0026ldquo;data model của mình chủ yếu là dạng quan hệ\u0026rdquo; hay \u0026ldquo;lượt đọc sẽ gấp 10 lần lượt ghi\u0026rdquo;, bạn được hai cái lợi. Một là mọi người có thể phản biện cách bạn đặt vấn đề trước khi bạn tiêu hết time-box. Hai là nếu giữa chừng có giả định hóa ra sai, thì bạn đã học được một điều có giá trị, chứ không phải phí mất hai ngày.\nNhững kiểu spike đi chệch hướng # Thất bại kinh điển nhất là phình phạm vi (scope creep): spike lặng lẽ biến thành một dự án nghiên cứu không có điểm dừng. Khi câu hỏi mới xuất hiện (và chắc chắn sẽ có), hãy ghi chúng lại thành ứng viên cho những spike sau, thay vì nới rộng spike hiện tại. Thất bại thứ hai là goldplating, tức làm quá mức cần thiết: khi bắt gặp mình đang thêm xử lý lỗi hay viết test cho mã spike, là bạn đã bước từ khám phá sang implement rồi đấy. Thứ ba là bỏ qua phần viết lại kết quả: sáu tháng sau sẽ có người gặp đúng câu hỏi đó, và hai ngày tìm hiểu của bạn coi như mất trắng. Và cuối cùng là coi spike như một lời cam kết cho một câu trả lời định sẵn. Một spike chứng minh được một hướng đi không khả thi là đã làm tròn vai của nó rồi.\nĐưa spike vào sprint planning # Khi lên kế hoạch sprint, tôi thấy vài thói quen sau khá hiệu quả. Nếu một story có những ẩn số thật, hãy làm spike trong sprint này và xếp phần việc thật vào một sprint sau. Mỗi sprint chỉ nên có một hai spike, nhiều hơn thì nhóm sẽ mất tập trung. Đừng chấm story point cho spike; spike tạo ra kiến thức chứ không tạo ra phần mềm, nên hãy theo dõi chúng bằng time-box. Và hãy trình bày kết quả spike trong buổi refinement, trước khi nhóm ước lượng các story liên quan, chứ không phải sau đó.\nLần tới, khi một story đặt ra nhiều câu hỏi hơn là câu trả lời, hãy nghĩ tới khả năng một spike lại chính là việc hữu ích nhất trong sprint. Đôi khi, cách nhanh nhất để đi tiếp là dừng lại và tìm hiểu.\nĐọc thêm # Bài viết liên quan trên blog này:\nRetrospective: Nhìn lại kết quả spike - Đưa những gì học được từ spike vào quá trình cải tiến liên tục Kiểm thử chấp nhận người dùng - Kiểm chứng xem các quyết định dựa trên spike có đáp ứng đúng nhu cầu người dùng không Tài liệu quyết định - Tổ chức các quyết định rút ra từ kết quả spike Lên kế hoạch API - Spike giúp định hướng các quyết định thiết kế API ra sao Tài liệu bên ngoài:\nSpikes - Scaled Agile Framework (SAFe) - Hướng dẫn của SAFe về spike ở quy mô lớn Spike Solutions - Extreme Programming - Khái niệm gốc trong XP The Scrum Guide - Spike nằm ở đâu trong sprint planning Martin Fowler bàn về nợ kỹ thuật - Spike giúp ngăn nợ kỹ thuật như thế nào ","date":"18/10/2023","description":"Cách tôi dùng spike để xử lý những story không ai ước lượng nổi: khi nào nên làm, tôi tổ chức một spike ra sao, và mẫu tài liệu tôi hay dùng.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-18-spike/","section":"Bài viết","summary":"","title":"Khi nhóm không ước lượng nổi: hãy dùng spike","type":"posts"},{"content":"Những buổi retrospective tệ nhất mà tôi từng ngồi đều giống nhau ở một điểm: nhóm không đạt sprint goal, thế là cả tiếng đồng hồ biến thành một buổi truy xét xem vì sao lại trượt. Còn những buổi tốt nhất thì gần như chẳng nhắc gì đến goal. Chúng tôi nói về cách mình đã làm việc, cái gì đã cản trở, và lần sau sẽ thử làm khác đi ra sao. Khác biệt đó chính là cái mà người ta gọi là tư duy không phụ thuộc vào kết quả (outcome-independent mindset). Sau một thời gian tự mình điều phối sprint, tôi nghĩ đây là thái độ hữu ích nhất mà một Scrum Master có thể có.\nNói cho rõ: kết quả là quan trọng. Chẳng ai trả lương cho chúng ta để có một quy trình đẹp đẽ mà không làm ra được gì. Nhưng hoàn thành sprint là việc của nhóm. Còn việc của Scrum Master là cải thiện cỗ máy làm ra các sprint. Đó là hai việc khác nhau, và phần lớn những trục trặc xoay quanh Scrum mà tôi từng thấy đều bắt nguồn từ chuyện nhầm lẫn giữa hai việc này.\nĐiều gì thay đổi khi bạn thôi bám chặt vào kết quả\nNhóm có không gian để thật sự làm chủ công việc. Về lý thuyết, Scrum dựa trên những nhóm tự tổ chức, nhưng một Scrum Master lo sốt vó cho từng kết quả thì rốt cuộc sẽ đi lái mọi quyết định, và nhóm sẽ sớm nhận ra quyết định thật ra không nằm trong tay họ. Còn khi bạn tập trung vào quy trình, bạn có thể để mọi người thử nghiệm, thỉnh thoảng quyết định sai, rồi rút kinh nghiệm từ đó. Đó cũng là cách duy nhất để tinh thần làm chủ thật sự hình thành.\nCác buổi retrospective trở nên thẳng thắn hơn. Khi câu hỏi đặt ra trong phòng là \u0026ldquo;sao mình không đạt con số?\u0026rdquo;, những câu trả lời thật sẽ lẩn đi hết. Còn khi câu hỏi là \u0026ldquo;cái gì làm mình chậm lại, và lần tới nên thử gì?\u0026rdquo;, mọi người sẽ chịu nói. Với những vướng mắc phát sinh giữa sprint cũng vậy: một Scrum Master bị ám ảnh bởi kết quả sẽ cuống lên vì hạn chót, còn người tập trung vào quy trình thì cứ thế bắt tay vào gỡ vướng mắc.\nStakeholder cũng bớt căng thẳng. Nếu thứ duy nhất bạn đem ra báo cáo là biểu đồ burndown, thì mỗi lần nó chững lại trông đều như thất bại. Nhưng nếu bạn kể thêm được nhóm đã học được gì và đang thích nghi ra sao, kỳ vọng của mọi người sẽ thực tế hơn, và nhóm cũng được che chắn khỏi rất nhiều áp lực mà nếu không thì sẽ đổ ập xuống giữa chừng sprint.\nNếu bạn đã lỡ bị ám ảnh bởi một kết quả nào đó\nTôi biết là nói thì dễ. Khi bạn đã dồn nhiều tâm huyết để một việc diễn ra theo đúng ý mình, thì câu \u0026ldquo;cứ tập trung vào quy trình thôi\u0026rdquo; nghe chẳng khác gì một câu khẩu hiệu dán trên tủ lạnh.\nCó vài điều thật sự giúp được. Thứ nhất, đừng tự trách mình vì đã quan tâm. Muốn có kết quả là chuyện bình thường, và giả vờ là mình không muốn thì chẳng ích gì. Thứ hai, chuyển mục tiêu về kết quả thành những mục tiêu về quy trình mà bạn kiểm soát được: không phải \u0026ldquo;thắng cuộc thi\u0026rdquo; mà là \u0026ldquo;luyện tập 30 phút mỗi ngày\u0026rdquo;. Bạn không kiểm soát được bản phát hành có suôn sẻ hay không, nhưng bạn kiểm soát được việc nó đã được review, kiểm thử và demo sớm hay chưa. Thứ ba, để ý xem điều gì đang nuôi nỗi ám ảnh đó. Nếu một ngày bạn mở lại một cái dashboard hay xem giá cổ phiếu cả chục lần, hãy đặt ra vài khung giờ cố định để xem, ngoài giờ đó thì thôi. Và hãy ăn mừng những thành công nhỏ trên đường đi, vì cái mục tiêu xa xôi kia sẽ sẵn sàng nuốt trọn sự chú ý của bạn nếu bạn để nó làm vậy. Còn nếu nỗi ám ảnh thật sự ảnh hưởng tới giấc ngủ hay sinh hoạt hằng ngày của bạn, thì nên tìm đến chuyên gia để được hỗ trợ. Chẳng có gì phải ngại cả.\nKết quả vẫn quan trọng. Nhưng những nhóm mà tôi thấy đạt kết quả đều đặn là những nhóm có người chăm lo cho phần \u0026ldquo;làm thế nào\u0026rdquo;, chứ không chỉ phần \u0026ldquo;làm cái gì\u0026rdquo;.\n","date":"16/10/2023","description":"Chỉ chăm chăm vào kết quả sprint là hiểu sai vai trò của Scrum Master. Vài suy nghĩ về việc dành sự quan tâm cho quy trình.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-16-outcome-indepedent/","section":"Bài viết","summary":"","title":"Vì sao Scrum Master nên tập trung vào quy trình, chứ không chỉ vào kết quả","type":"posts"},{"content":"Bao nhiêu năm đi làm, những người quản lý mà tôi đánh giá cao nhất đều có chung một thói quen: gặp chuyện gì không biết thì họ nói thẳng là không biết. Không chém gió, cũng không nói vòng vo cho qua. Chỉ đơn giản là: “Tôi không biết, để mình tìm hiểu xem.”\nDạo này tôi hay nghĩ về chuyện đó, vì chính tôi cũng đang dẫn dắt dự án, và tôi hiểu cảm giác muốn chém gió là có thật. Khi có người hỏi bạn một câu giữa cuộc họp và năm người khác đang chờ bạn trả lời, nói “Tôi không biết” nghe cứ như mình thất bại. Nhưng không phải vậy. Phần lớn thời gian, đó lại là câu trả lời có sức nặng nhất mà bạn có thể đưa ra.\nChém gió sớm muộn cũng lộ # Công nghệ phần mềm thay đổi quá nhanh, không ai nhớ hết được mọi thứ, còn kỹ sư thì đứng tận cuối phòng cũng nhận ra ai đang chém gió. Lần đầu tiên cả nhóm bắt gặp bạn giả vờ biết một điều gì đó, họ sẽ lặng lẽ bớt tin mọi điều khác bạn nói. Ngược lại, một người quản lý dám thừa nhận chỗ mình còn hổng rồi đi tìm hiểu cho ra thì lại được điều ngược lại: mọi người tin những câu trả lời thật của họ, vì ai cũng nhận ra đâu là câu trả lời thật khi nghe thấy.\nĐó là điểm khởi đầu, chứ chưa phải câu trả lời # Mấu chốt nằm ở những gì bạn làm sau đó. Nói “Tôi không biết” rồi thôi thì cũng chỉ là nhún vai cho qua. Những người quản lý giỏi sẽ làm tiếp một bước: đặt những câu hỏi tốt hơn, kiểu “cái này so với thứ mình đang dùng thì thế nào?”, “nếu mình nhận định sai chỗ này thì cái gì sẽ hỏng?”, rồi mang những câu hỏi đó đến đúng người. Nhóm nào cũng có một người gần như sống luôn trong cơ sở dữ liệu, một người thuộc lòng mọi edge case khó chịu ở frontend. Bạn không cần biết mọi thứ; bạn chỉ cần biết ai là người biết, và chịu khó đi hỏi họ.\nQuyết định tốt hơn # Trong phần mềm, một quyết định đã đưa ra thường sẽ tồn tại khá lâu, nên cần thành thật xem phần nào mình chắc chắn và phần nào mình chỉ đang đoán. Thừa nhận chỗ hổng ngay từ đầu thì bạn sẽ đi tìm thêm thông tin còn thiếu trước khi quyết định, chứ không phải sau đó. Nó cũng cho những người không đồng tình với bạn cơ hội lên tiếng, đúng vào lúc bạn cần nghe họ nhất. Khi đã quyết rồi, hãy giải thích lý do. Và nếu hóa ra bạn sai, cũng hãy nói luôn. Không gì làm một nhóm mất đi sự thẳng thắn nhanh bằng một người quản lý không chịu nhận là mình đã quyết định sai.\nChẳng có gì phức tạp ở đây cả. Hãy đặt câu hỏi, lắng nghe những người biết nhiều hơn mình (lúc nào cũng có người biết nhiều hơn bạn), và nói rõ khi mình đang đoán. Câu “Tôi không biết, nhưng để mình tìm hiểu xem” đã giúp tôi có uy tín hơn bất kỳ câu trả lời chém gió cho qua chuyện nào tôi từng đưa ra.\n","date":"13/10/2023","description":"Những người quản lý tôi tin tưởng nhất đều là người dám thừa nhận khi mình không biết. Vài suy nghĩ về lý do vì sao điều đó lại hiệu quả.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-13-i-dont-know/","section":"Bài viết","summary":"","title":"Vì sao engineering manager giỏi dám nói “Tôi không biết”","type":"posts"},{"content":"Cách đây không lâu, tôi có viết một bài nói chung chung về phát triển theo hướng OpenAPI-first. Bài này thì cụ thể hơn: đây là quy trình lên kế hoạch mà nhóm tôi đang chạy thử, kèm theo tệp YAML mẫu.\nChuyện này bắt nguồn từ đâu # Mọi chuyện bắt đầu từ một buổi retro. Góp ý hôm đó khá thẳng thắn: gần như không ai nắm được khâu lên kế hoạch API đang diễn ra thế nào. Backend xây endpoint theo cách hiểu yêu cầu của riêng mình, frontend chỉ biết response có cấu trúc ra sao khi mọi thứ đã xong xuôi, và những chỗ vênh nhau chỉ lộ ra lúc tích hợp, đúng thời điểm sửa tốn kém nhất. Đề xuất đưa ra là kéo cả hai bên vào ngay từ giai đoạn lên kế hoạch, và lấy OpenAPI schema làm trục chính cho mọi thứ xoay quanh.\nQuy trình # Mỗi tuần, chúng tôi chọn ra hai đại diện, một người bên backend và một người bên frontend. Nhiệm vụ của họ là nắm rõ yêu cầu của sprint và chỉnh schema OpenAPI dùng cho khâu lên kế hoạch sao cho khớp với yêu cầu đó. Hai người cùng review schema, sửa những chỗ chưa khớp, và đến cuối buổi thì cả hai bên đều biết chính xác sẽ xây cái gì, trước khi có ai bắt tay vào làm.\nKhi các chỉnh sửa đã chốt xong, schema được commit vào repo, và CI/CD sẽ sinh ra mock API từ tệp yaml đó. Hết sprint, chúng tôi tạo một tệp yaml mới cho yêu cầu API của sprint kế tiếp, và vòng lặp cứ thế tiếp tục.\nGhi chú về công cụ # Để xem tệp yaml một cách trực quan thì Swagger Editor là đủ: tải tệp lên là bạn sẽ thấy nó hiển thị thành một tài liệu Swagger đàng hoàng. Schema này chỉ phục vụ khâu lên kế hoạch. Khi API thật đã deploy, service nên tự sinh ra openapi.json của riêng nó; bạn có thể tải tệp đó về rồi dùng lại cho vòng lên kế hoạch tiếp theo. Pipeline CI/CD cần nhận vào một tệp yaml và dựng mock server từ đó. Với việc này, openapi-mock trông có vẻ là một lựa chọn đáng tin. Sao phải mất công như vậy # Vì làm vậy thì các cuộc tranh cãi sẽ diễn ra ở nơi rẻ nhất có thể. Khi có một kỹ sư frontend và một kỹ sư backend cùng ngồi viết schema, những chỗ hiểu lầm sẽ được phát hiện trước khi kịp biến thành mã. Yêu cầu API chỉ cần thống nhất một lần, thay vì phải thương lượng lại giữa sprint. Nhờ có mock server, frontend bắt tay vào làm được ngay chứ không phải ngồi chờ. Và số chi tiết quan trọng bị bỏ sót cũng ít hơn hẳn, vì muốn lọt được thì chúng phải qua mắt hai người, lại còn phải qua một bản hợp đồng giấy trắng mực đen.\nChẳng có gì mang tính cách mạng ở đây cả. Chỉ là kế hoạch được làm cụ thể đến mức máy có thể dựng lại nó thành mock cho bạn dùng. Và hóa ra, đó cũng chính là mức cụ thể mà một bản kế hoạch cần có.\nPhụ lục A - Ví dụ yaml # openapi: 3.1.0 info: title: PKY Sprint 3 description: |- PKY Sprint 3 Development Schema termsOfService: contact: email: jared@lynskey.co.nz license: name: None url: version: 1.0.11 externalDocs: description: url: servers: - url: https://api-mock.com/api/v3 tags: - name: curation description: PKY-1081 Curated Lists Feature externalDocs: description: Jira Epic url: https://pickydev.atlassian.net/browse/PKY-1081 paths: /curations: get: tags: - curation summary: Return the curations created by users description: Returns a map of status codes to quantities operationId: getCurations responses: \u0026#39;200\u0026#39;: description: successful operation content: application/json: schema: type: object additionalProperties: type: integer format: int32 security: - api_key: [] /curations/{curationId}: get: tags: - curation summary: Find purchase order by ID description: For valid response try integer IDs with value \u0026lt;= 5 or \u0026gt; 10. Other values will generate exceptions. operationId: getOrderById parameters: - name: orderId in: path description: ID of order that needs to be fetched required: true schema: type: integer format: int64 responses: \u0026#39;200\u0026#39;: description: successful operation content: application/json: schema: $ref: \u0026#39;#/components/schemas/Order\u0026#39; application/xml: schema: $ref: \u0026#39;#/components/schemas/Order\u0026#39; \u0026#39;400\u0026#39;: description: Invalid ID supplied \u0026#39;404\u0026#39;: description: Order not found delete: tags: - curation summary: Delete purchase order by ID description: For valid response try integer IDs with value \u0026lt; 1000. Anything above 1000 or nonintegers will generate API errors operationId: deleteOrder parameters: - name: orderId in: path description: ID of the order that needs to be deleted required: true schema: type: integer format: int64 responses: \u0026#39;400\u0026#39;: description: Invalid ID supplied \u0026#39;404\u0026#39;: description: Order not found components: schemas: Curation: type: object properties: id: type: integer format: int64 examples: [10] curationId: type: integer format: int64 examples: [198772] quantity: type: integer format: int32 examples: [7] status: type: string description: Order Status examples: [approved] enum: - placed - approved - delivered complete: type: boolean xml: name: order ApiResponse: type: object properties: code: type: integer format: int32 type: type: string message: type: string xml: name: \u0026#39;##default\u0026#39; requestBodies: Curation: description: Curation object that needs to be added content: application/json: schema: $ref: application/xml: schema: $ref: securitySchemes: petstore_auth: type: oauth2 flows: implicit: authorizationUrl: scopes: write:curations: modify curations in your account read:curations: read your curations api_key: type: apiKey name: api_key in: header ","date":"09/10/2023","description":"Quy trình lên kế hoạch bằng OpenAPI mà nhóm tôi áp dụng, để frontend và backend thống nhất contract với nhau trước khi sprint bắt đầu, kèm một tệp YAML mẫu.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-08-planning-an-api/","section":"Bài viết","summary":"","title":"Lên kế hoạch API trước khi bắt tay xây dựng: quy trình OpenAPI-first","type":"posts"},{"content":"Trong những dự án tôi từng làm, thứ làm trễ tiến độ đều đặn nhất không phải là những bài toán kỹ thuật khó, mà là cảnh frontend ngồi không chờ một endpoint \u0026ldquo;sắp xong rồi\u0026rdquo;. Cách chữa mà nhóm tôi ở công ty chốt lại vừa nhàm vừa hiệu quả: thống nhất API contract bằng OpenAPI trước, rồi mới có ai bắt đầu viết mã.\nCái vòng chờ đợi # Quy trình truyền thống thì chạy tuần tự. Backend thiết kế và xây API, và chỉ khi endpoint đã chạy được thật thì frontend mới bắt đầu tích hợp. Backend chậm ngày nào thì lịch của frontend lãnh đủ ngày đó, mà ngày phát hành thường lại phụ thuộc vào lịch của frontend.\nContract trước, mã nguồn sau # Với quy trình schema-first, hai bên bắt đầu gần như cùng lúc. Trước khi có dòng mã nào, toàn bộ cấu trúc của API được đưa vào một OpenAPI schema: mọi endpoint, model của request và response, phương thức xác thực và mã lỗi. Tài liệu đó trở thành bản hợp đồng giữa frontend và backend.\nLợi ích thấy ngay. Trước khi tính năng được xây, ai cũng hiểu nó theo cùng một cách. Frontend có thể dựng giao diện trong lúc backend vẫn đang implement. Khi có thay đổi thì xử lý cũng nhanh hơn, vì chỉ có một nguồn thông tin chuẩn đã được thống nhất cần cập nhật. Và bạn có thể sinh mock server thẳng từ schema, nên frontend bắt đầu kiểm thử được ngay cả khi chưa có endpoint thật nào.\nÁp dụng thực tế ra sao # Thu thập yêu cầu, như với bất kỳ việc gì khác. Thiết kế schema: endpoint, model request/response, xác thực, mã lỗi. Cho cả frontend lẫn backend cùng review. Đây là lúc bản hợp đồng thể hiện rõ giá trị của nó. Sinh mock server (Swagger và Postman đều làm được) và để frontend tích hợp với mock đó. Backend implement các endpoint thật theo đúng schema. Khi endpoint thật lần lượt xong thì thay dần mock bằng endpoint thật, đồng thời kiểm thử liên tục để chắc chắn hai bên vẫn làm đúng hợp đồng. Tiếp tục trao đổi với nhau. Schema là tài liệu sống, chứ không phải thứ khắc lên bia đá. Những điểm vướng # Tất nhiên cách này cũng có cái giá của nó. Viết một schema kỹ lưỡng ngay từ đầu tốn khá nhiều thời gian. Khi yêu cầu thay đổi thì schema phải đổi trước, và ảnh hưởng sẽ lan sang cả hai nhóm. Ngoài ra, ai cũng cần biết dùng OpenAPI và các công cụ đi kèm ở mức cơ bản.\nDù vậy, tôi chưa từng thấy trường hợp nào mà công sức bỏ ra lúc đầu lại tốn hơn mớ hỗn loạn lúc tích hợp mà nó giúp tránh được. Frontend bắt tay vào làm từ ngày đầu tiên, còn các cuộc tranh cãi thì diễn ra trên một bản tài liệu, chứ không phải trên những bản build hỏng.\n","date":"08/10/2023","description":"Thống nhất contract OpenAPI ngay từ đầu, cộng thêm một mock server, giúp frontend không phải ngồi chờ backend như thế nào.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-08-open-api/","section":"Bài viết","summary":"","title":"Phát triển theo hướng OpenAPI-first: frontend và backend làm song song","type":"posts"},{"content":"Hồi còn ở New Zealand, tôi từng làm một năm ở bộ phận vận hành mạng (network operations) của nhà mạng Spark, và bài học đọng lại khá đơn giản: con người sẽ mắc lỗi, hệ thống sẽ hỏng, và chúng cứ theo lịch của riêng mình, chẳng thèm hỏi bạn đã sẵn sàng chưa. Thứ duy nhất bạn kiểm soát được là mình đã chuẩn bị đến đâu khi chuyện xảy ra.\nĐể mọi người dám nhận lỗi # Cải thiện lớn nhất lại không nằm ở kỹ thuật. Nếu mắc lỗi là bị phạt, người ta sẽ giấu lỗi, và một lỗi bị giấu sẽ được sửa muộn hơn đáng lẽ hàng giờ, thậm chí hàng ngày. Một đội mà ai đó dám nói “Tôi làm hỏng rồi, đây là chính xác những gì tôi đã làm” chỉ trong vài phút thì lúc nào cũng khắc phục nhanh hơn. Bước tiếp theo cũng quan trọng không kém: đừng dừng lại ở chỗ vá triệu chứng. Hãy đào đến tận nguyên nhân gốc, không thì cùng một lỗi sẽ quay lại dưới một hình dạng khác.\nGiảm khả năng mắc lỗi ngay từ đầu # Ở phần này, ba việc nhàm chán gánh gần hết công sức. Một là giữ kỹ năng luôn được cập nhật — lỗi rất “ưa” kiến thức lỗi thời, nên đào tạo định kỳ là bảo trì chứ không phải phúc lợi. Hai là viết quy trình ra thành văn bản — rất nhiều “lỗi do con người” thực chất là lỗi do mơ hồ, ai đó phải đoán vì tài liệu vốn không tồn tại. Ba là tự động hoá những việc lặp đi lặp lại: con người cực kỳ kém khoản làm đúng một việc năm trăm lần liền, còn máy tính thì sinh ra chính là để làm việc đó. Ngoài ba việc này, hãy để thêm một người nữa soát lại mọi thứ có rủi ro. Review chéo, hay thậm chí chỉ là một bước tự kiểm tra có bài bản, cũng bắt được một tỷ lệ lỗi nhiều đến đáng xấu hổ trước khi chúng kịp lọt ra ngoài.\nLên kế hoạch cho sự cố lớn trước khi nó ập đến # Hãy bắt đầu bằng một bản đánh giá rủi ro trung thực — điểm yếu bên trong như hạ tầng IT, mối đe doạ bên ngoài như thiên tai — và thiết lập giám sát theo thời gian thực làm hệ thống cảnh báo sớm. Sau đó viết ra các kế hoạch: kế hoạch duy trì hoạt động kinh doanh (business continuity plan) cho các sự cố gián đoạn nói chung, và kế hoạch khôi phục sau thảm hoạ (disaster recovery plan) dành riêng cho mảng IT. Kế hoạch chưa thử thì chỉ là phỏng đoán, nên hãy tổ chức diễn tập. Quyết định trước ai sẽ báo gì cho stakeholder nào, vì truyền thông khủng hoảng kiểu ứng biến chính là cách sự hoảng loạn lan ra. Và đừng bỏ qua những việc chẳng mấy hào nhoáng còn lại: giữ liên lạc với chính quyền địa phương, bảo trì đều đặn, mua đúng loại bảo hiểm.\nChẳng việc nào ở đây thú vị cả, có lẽ vì thế mà nó hiệu quả. Những công ty xử lý sự cố tốt không phải nhờ may mắn — họ đã lên kế hoạch cho sự cố từ khi mọi thứ còn yên ổn.\n","date":"07/10/2023","description":"Ghi chép về cách để lỗi do con người không biến thành thảm hoạ: báo lỗi mà không sợ bị đổ lỗi, tự động hoá những việc nhàm chán, và viết sẵn kế hoạch ứng phó trước khi cần đến.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-07-handling-human-errors/","section":"Bài viết","summary":"","title":"Xử lý lỗi do con người và chuẩn bị cho thảm hoạ","type":"posts"},{"content":"Lượt xem và upvote có một khuyết điểm dễ thấy: chúng chỉ thưởng cho những gì vốn đã nổi. Giàu càng thêm giàu, nội dung mới thì chết đói dưới đáy feed, và dần dần nền tảng biến thành một kênh chiếu lại. Ở chỗ tôi làm, xếp hạng feed cho tốt là một trong những bài toán khó mà ít ai để ý, nên tôi đã dành khá nhiều thời gian nghĩ xem còn những tín hiệu nào khác đáng tin.\nNhìn vào việc người ta làm, đừng chỉ nhìn chỗ họ bấm # Người dùng hiếm khi chủ động góp ý, mà có góp ý thì cũng thiên lệch; ngược lại, dữ liệu hành vi thì có rất nhiều. Thời gian ở lại trang và độ sâu cuộn cho biết người ta có đọc thật hay bỏ đi ngay sau đoạn đầu. Lượt quay lại còn tốt hơn nữa — quay lại đọc một bài là lời khen mạnh hơn bất kỳ upvote nào. Còn thoát ra nhanh là một tín hiệu tiêu cực đáng giá: nội dung đã hứa hẹn một điều mà người đọc không nhận được.\nTín hiệu nằm sẵn trong chính nội dung # Nội dung tốt không phải lúc nào cũng gom được lượt thích ngay từ đầu, nhưng thường nó thể hiện chất lượng theo những cách khác. Tính mới là một — bài này có nói về điều gì mà nền tảng chưa từng có không? Cấu trúc là một yếu tố nữa; trình bày gọn gàng, ngữ pháp ổn và diễn đạt rõ ràng đi đôi với chất lượng nhiều hơn ta muốn thừa nhận. Lịch sử của tác giả cũng là một điểm xuất phát hợp lý, miễn là xét theo khả năng giữ chân người đọc của các bài cũ chứ không phải số lượt thích thô. Bình luận sớm cũng có ích: ba bình luận tích cực thật lòng đáng giá hơn ba mươi upvote bấm theo phản xạ, và chỉ cần phân tích ngữ nghĩa một chút là đã có thể đánh dấu những nội dung lấp được khoảng trống mà nền tảng đang thiếu.\nMượn tín hiệu từ bên ngoài # Internet rộng lớn ngoài kia cho bạn biết cái gì đang được quan tâm ngay lúc này. Nội dung khớp với những từ khoá tìm kiếm đang lên thì đã có sẵn độ liên quan, và bài nào đăng nhanh về một chủ đề đang nóng thì xứng đáng được đẩy lên. Những gì đang rôm rả trên Twitter hay Reddit thường là bản xem trước cho những gì người dùng của bạn sẽ muốn tiếp theo — chỉ cần dự đoán một chút, bạn có thể đẩy nội dung mới phù hợp lên trước khi con sóng tới, thay vì chạy theo sau. Ô tìm kiếm ngay trên nền tảng của bạn cũng hay bị đánh giá thấp: nếu người dùng cứ tìm mãi một chủ đề, hay một bài đều đặn kéo traffic từ công cụ tìm kiếm về, thì đó là độ liên quan mà bạn chẳng cần phải đoán.\nRồi kiểm thử cho sòng phẳng # Tín hiệu có khéo đến đâu cũng phải đem đi A/B test — cho một nhóm nhỏ người dùng xem nội dung mới rồi so mức tương tác với những bài đang giữ vị trí. Và để ý xem nội dung lan ra thế nào: lượt chia sẻ, lượt nhắc đến và lượt nhúng, cả trong lẫn ngoài nền tảng, nói lên giá trị nhiều hơn hẳn cái bộ đếm lượt xem.\nKhông chỉ số đơn lẻ nào trụ nổi khi va chạm với thực tế. Những cách xếp hạng thật sự hiệu quả đều pha trộn hành vi người dùng, chất lượng nội tại, tính thời sự và những thử nghiệm sòng phẳng, và không tín hiệu nào được quyền quyết định sau cùng.\n","date":"06/10/2023","description":"Lượt xem và upvote chỉ thưởng cho những gì vốn đã nổi, nên đây là những tín hiệu kín đáo hơn mà feed có thể dùng để đưa nội dung mới lên.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-06-reinenforcement-learning/","section":"Bài viết","summary":"","title":"Xếp hạng nội dung: đừng chỉ nhìn lượt xem và lượt thích","type":"posts"},{"content":"Năm nay tôi đọc hướng dẫn SEO nào cũng thấy nói na ná nhau, theo một thứ tự na ná nhau. Tuỳ cách nhìn, đó có thể là điều khiến bạn yên tâm, cũng có thể là dấu hiệu đáng lo. Tôi đã nghiền ngẫm chuyện này một thời gian — một phần cho blog này, phần còn lại là cho công việc. Ở chỗ làm, traffic từ tìm kiếm tự nhiên (organic search) quan trọng hơn tôi muốn thừa nhận.\nVì sao đi theo số đông phần lớn là ổn # Best practice được gọi là best practice là có lý do cả. Chúng được đúc kết từ dữ liệu của hàng nghìn trang web, và nếu bạn mới làm SEO thì chỉ cần theo chúng là đã đi được gần hết quãng đường mà chẳng gặp trắc trở gì. Đi theo lối mòn cũng có nghĩa là ít gặp bất ngờ khó chịu hơn — làm đúng y những gì chính tài liệu của Google khuyến nghị thì khó mà bị một bản cập nhật thuật toán phạt. Mà thuật toán thì thay đổi liên tục, nên riêng chuyện theo kịp hướng dẫn hiện hành thôi cũng đã là cả một khối việc.\nVấn đề là ai cũng đang đọc cùng những hướng dẫn đó. Khi mọi trang trong một ngách đều có cùng một cấu trúc, cùng những tiêu đề nhồi từ khoá và cùng một khối FAQ gắn vội ở cuối trang, thì chẳng trang nào nổi bật cả. Bạn cũng sẽ mãi ở thế bị động: ngồi chờ bản cập nhật tiếp theo, rồi cuống cuồng sửa theo cho kịp.\nLý do nên thử cách của riêng mình # Thử nghiệm là cách để bạn tìm ra những chiến thuật hợp riêng với trang của mình. Cái hiệu quả với một cộng đồng mỹ phẩm chưa chắc đã hiệu quả với một blog nấu ăn, và không checklist chung chung nào chỉ ra được sự khác biệt đó. Nếu một thử nghiệm thành công, bạn sẽ có một khoảng thời gian là người duy nhất làm như vậy. Còn nếu thành công ấy được nhiều người biết đến, người ta sẽ bắt đầu coi bạn là hình mẫu để học theo, và điều đó cũng mang lại giá trị cộng dồn của riêng nó.\nNói thật thì mặt trái là phần lớn thử nghiệm đều thất bại trong im lặng. Bạn có thể đổ vài tuần vào một thử nghiệm mà cuối cùng chẳng có gì để khoe. Nếu traffic từ tìm kiếm là thứ nuôi sống cả công ty, thì thật khó thuyết phục người đang duyệt quỹ thời gian của bạn.\nTôi chốt lại thế nào # Làm những việc nhàm chán trước, và làm cho đến nơi đến chốn. Sau đó dành ra một phần công sức nhỏ, có giới hạn rõ ràng cho thử nghiệm, và đo lường kết quả hẳn hoi thay vì đánh giá theo cảm giác. Trang nhỏ thì nên dựa chủ yếu vào những cách đã được kiểm chứng, chỉ thử nghiệm ở phần rìa; còn tổ chức lớn, nguồn lực dư dả thì đủ sức đặt những ván cược thật.\nQuy tắc duy nhất trụ vững qua mọi bản cập nhật thuật toán cũng là quy tắc kém hấp dẫn nhất: làm ra thứ thật sự hữu ích cho người đang tìm kiếm. Mọi thứ khác chỉ là mốt nhất thời.\n","date":"04/10/2023","description":"Khi nào chép best practice SEO là có lợi, và khi nào đáng để thử cách riêng của mình.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-04-following-seo-trends/","section":"Bài viết","summary":"","title":"SEO: Đi theo trào lưu có sẵn hay tự tạo xu hướng mới?","type":"posts"},{"content":"Làm việc cho một nền tảng nội dung giống như được ngồi hàng ghế đầu xem một trò xổ số kỳ lạ: hai bài chất lượng ngang nhau cùng được đăng lên, một bài lặng lẽ chìm nghỉm, bài kia thì bùng nổ. Lời giải thích gần đúng nhất mà tôi tìm được là valence — sắc thái cảm xúc mà một nội dung mang theo, dù là tích cực, tiêu cực hay trung tính.\nTích cực, tiêu cực, trung tính # Nội dung mang valence tích cực khiến người ta thấy dễ chịu: những câu chuyện ấm lòng, meme ngớ ngẩn, tin tức truyền cảm hứng. Chúng được chia sẻ vì chuyển tiếp cho người khác cũng giống như tặng một món quà nhỏ, một khoảng nghỉ giữa guồng quay thường ngày. Điều làm tôi bất ngờ hơn là valence tiêu cực lại hiệu quả đến vậy. Giận dữ, buồn bã và ghê tởm là những động lực chia sẻ cực mạnh — người ta chuyền tay nhau những câu chuyện về bất công, về nhân vật gây tranh cãi hay các vấn đề xã hội để nhiều người cùng biết, hoặc để kéo người khác về phe mình. Còn nội dung trung tính thì không khuấy động nhiều cảm xúc, nhưng vẫn được chia sẻ nhờ giá trị thực: một sự thật thú vị thật sự, một khám phá mới, một bài hướng dẫn giải quyết được vấn đề có thật.\nCường độ mới là thứ lan xa # Có một quy luật đúng với tất cả: cảm xúc càng mạnh, người ta càng dễ bình luận, thả tim hay chia sẻ. Một bài dễ chịu nhè nhẹ gần như lúc nào cũng thua một bài khiến người ta tức điên. Còn chuyện đó có nói lên điều gì tốt đẹp về chúng ta hay không thì lại là câu hỏi khác.\nNhững loại nội dung hay bùng nổ # Có vài loại nội dung sinh ra đã có lợi thế. Những ý tưởng gây tranh cãi, vì người ta chia sẻ để đồng tình hoặc để phản bác, và các chủ đề gây phân cực thì lan đi rất nhanh. Kiểu hài hước dựa trên trải nghiệm chung — trò đùa nội bộ mà cả một cộng đồng đều hiểu ngay. Những câu chuyện đời thường về người bình thường vượt qua hoàn cảnh khó khăn. Bất cứ nội dung nào bắt trend với một góc nhìn thật sự mới mẻ, thay vì xào lại quan điểm cũ. Và các định dạng tương tác — quiz, bình chọn, thử thách — vì đã tham gia là đi được nửa đường tới chỗ chia sẻ.\nTất cả những điều này cộng lại cũng không thành công thức, và tôi luôn nghi ngờ bất cứ ai rao bán công thức. Nhưng nếu phải đặt cược vào một biến số duy nhất, tôi sẽ chọn điều này: nội dung khiến người ta cảm thấy một điều gì đó thật mạnh mẽ sẽ thắng nội dung không làm được vậy, tuần này qua tuần khác.\n","date":"04/10/2023","description":"Sắc thái cảm xúc mà một nội dung mang lại — hay còn gọi là valence — là thứ gần nhất với một công cụ dự đoán độ viral mà tôi từng thấy.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-05-viral-success/","section":"Bài viết","summary":"","title":"Vì sao có những nội dung lại viral: vai trò của sắc thái cảm xúc (valence)","type":"posts"},{"content":"Tôi là một lập trình viên người New Zealand (dân Kiwi) đang sống ở Seoul, và sản phẩm tôi làm là một cộng đồng dành cho những người rất nghiêm túc với chuyện skincare và mỹ phẩm. Tôi thì không phải kiểu người đó. Quy trình skincare của tôi chỉ có mỗi kem chống nắng, mà phải hôm nào siêng mới có. Vậy nên trực giác của tôi về những gì người dùng muốn sai thường xuyên đến mức tôi đã học được cách thôi tin vào nó.\nNếu bạn đang làm sản phẩm cho một nhóm người có nhu cầu, xuất thân hay sở thích khác mình, thì cảm tính của bạn đang bị lệch. Dưới đây là những gì tôi làm để bù lại.\nNghiên cứu thắng trực giác # Khi cảm tính của bạn đã quen với nhầm kiểu người dùng, cách sửa duy nhất là tiếp xúc với người dùng thật. Khảo sát và phỏng vấn cho bạn nguyên liệu thô — cứ hỏi câu hỏi mở rồi im lặng mà nghe, người ta sẽ nói chính xác điều gì làm họ bực mình. Lặng lẽ theo dõi các diễn đàn và cộng đồng nơi người dùng của bạn vốn đã tụ tập thì còn rẻ hơn nữa, và những lời than phiền hay hào hứng hằng ngày ở đó cho bạn biết nhiều hơn bất kỳ buổi workshop nào. Các báo cáo và nghiên cứu có sẵn giúp bổ sung thêm; chúng hữu ích nhất khi bạn cần kiểm tra xem quy luật mình phát hiện ra là có thật, hay chỉ là tiếng của ba người nói to nhất.\nHiểu cách họ nghĩ, rồi sống thử nếp sinh hoạt của họ # Empathy map (bản đồ thấu cảm) nghe như một màn trình diễn của dân tư vấn, nhưng ngồi viết ra xem một persona nghĩ gì, cảm thấy gì, nói gì và làm gì sẽ buộc bạn nhận ra những chỗ mình đang áp sở thích của bản thân lên người khác. Mấy tờ giấy note không phải là trọng tâm. Trọng tâm là bắt quả tang chính mình đang giả định.\nCách mạnh tay hơn là đắm mình hẳn vào thế giới của họ: dùng những sản phẩm người dùng của bạn dùng, xem những nội dung họ xem, đến những nơi họ đến. Tự tay thử những quy trình skincare mà người dùng bên tôi hay bàn tán, tôi hiểu ra sản phẩm cần giải thích những gì nhiều hơn hẳn so với đọc bất kỳ tài liệu spec nào.\nLàm prototype, kiểm thử, lắng nghe, lặp lại # Nghiên cứu kỹ đến đâu cũng không trụ nổi lần đầu va chạm với một prototype. Vậy nên hãy làm phiên bản nhỏ nhất có thể đem đi kiểm thử, đưa cho đúng người dùng mục tiêu dùng thử, và coi sự bối rối của họ là dữ liệu, chứ đừng cho rằng họ “dùng sai cách”. Sau khi ra mắt cũng nhớ giữ các kênh góp ý thật luôn mở, vì thấu hiểu người dùng không phải việc làm một lần là xong — tệp người dùng thay đổi, trào lưu dịch chuyển, và insight của năm ngoái cứ thế lặng lẽ cũ đi. Vòng lặp này không có điểm kết thúc. Mà vậy cũng chẳng sao.\nMượn những đôi mắt tinh hơn # Có những khoảng cách bạn không thể tự mình lấp. Đồng nghiệp nào thật sự thuộc nhóm người dùng mục tiêu sẽ nhận ra những thứ bạn không bao giờ thấy, và tôi thì dựa vào những đồng nghiệp như vậy suốt. Khi khoảng cách giữa bạn và người dùng là cả một hẻm núi chứ không chỉ là một vết nứt, hãy tuyển người để lấp nó — thuê chuyên gia tư vấn nếu buộc phải thế, còn tốt hơn nữa là có một thành viên trong đội mà bản thân họ chính là người dùng.\nBên dưới tất cả là một nguyên tắc cần giữ: luôn nhớ rằng mục tiêu là làm ra thứ chạm được đến người dùng, chứ không phải thứ làm bạn hài lòng. Tôi vẫn hay bắt gặp mình đang thiết kế cho chính mình. Nghiên cứu, kiểm thử và những đôi mắt đi mượn chỉ là cách để phát hiện ra chuyện đó sớm. Làm đều đặn như vậy thì bạn sẽ có một sản phẩm hợp với những người thật sự dùng nó — mà đó mới là mục đích ngay từ đầu.\n","date":"03/10/2023","description":"Những điều tôi học được khi làm một sản phẩm mà người dùng mục tiêu chẳng giống tôi chút nào.","externalUrl":null,"permalink":"/vi/posts/2023/2023-10-03-following-personas/","section":"Bài viết","summary":"","title":"Thiết kế cho những người dùng không giống mình","type":"posts"},{"content":"Vì sao người ta lại muốn những thứ họ muốn? Tôi nghĩ về câu hỏi này nhiều hơn mình tưởng. Tôi làm cho một nền tảng mà người dùng bàn chuyện mỹ phẩm suốt cả ngày, và chuyện vì sao một sản phẩm được săn lùng còn một món gần như y hệt lại chẳng ai ngó ngàng thì gần như là toàn bộ bài toán kinh doanh. Gu thì dĩ nhiên mỗi người mỗi khác, nhưng những quy luật nằm bên dưới lại nhất quán đến bất ngờ.\nPhải dùng được, và dùng được lâu\nMong muốn thường bắt đầu từ công dụng rất giản dị: món đồ bếp giúp bạn tiết kiệm được mười phút thật, chiếc điện thoại cứ thế giữ cho bạn luôn kết nối. Nhưng chỉ hữu dụng thôi thì mới dừng ở mức “cũng được”. Những thứ người ta thật sự thèm muốn phải làm tốt vượt trội việc của chúng, và giữ được phong độ đó lâu dài. Chất lượng và độ bền là thứ biến một món hàng mua về thành một vật sở hữu mà người ta sẵn sàng bênh vực — một chiếc xe cổ, một con dao dùng lâu hơn cả đời chủ. Chẳng ai mê mẩn một thứ mà họ biết trước là sẽ hỏng.\nPhải khiến bạn cảm thấy điều gì đó\nThẩm mỹ có sức hút mạnh hơn ta muốn thừa nhận. Đường nét của một chiếc xe thể thao hay cách bày biện một món ăn ngon đã hút mắt bạn trước cả khi lý trí kịp lên tiếng. Và những mong muốn mạnh nhất là mong muốn thuộc về cảm xúc: niềm vui, nỗi hoài niệm, cảm giác được thuộc về một nơi nào đó. Một món đồ chơi sờn cũ từ thời thơ ấu chẳng qua được bài kiểm tra công năng nào, vậy mà vẫn không tài nào vứt đi được. Cá nhân hoá cũng đánh vào đúng bản năng ấy. Một thứ được làm riêng cho bạn thì mang cảm giác là của bạn, điều mà bản đại trà không bao giờ có được.\nNgười khác quyết định nhiều hơn ta tưởng\nSự khao khát một phần là do xã hội tạo nên. Trào lưu, sự tán thưởng của những người xung quanh và chuẩn mực văn hoá lặng lẽ định hướng những gì ta muốn. Sự khan hiếm cũng vậy: phiên bản giới hạn, đá quý hiếm, món đồ chỉ có một. Độ hiếm ngầm báo rằng muốn có thứ này là bạn đã bước chân vào một câu lạc bộ nhỏ. Rồi danh tiếng lại khuếch đại tất cả — những lời khen nức nở trong phần đánh giá và lời truyền miệng khiến người ta thấy muốn thứ đó là an toàn. Ngày nào đi làm tôi cũng thấy vòng lặp này diễn ra.\nBài toán tiền nong vẫn phải hợp lý\nGiá cao không giết chết mong muốn, nhưng cảm giác bị hớ thì có. Người ta sẵn lòng trả thêm cho đồ xa xỉ, nhưng vẫn quay lưng với bất cứ thứ gì có vẻ không đáng đồng tiền mồ hôi nước mắt. Và ngày càng nhiều người đưa cả giá trị sống của mình vào cùng phép tính đó: một món đồ có được làm ra theo cách bền vững, có đạo đức hay không giờ có thể đẩy nó lên hoặc kéo nó xuống trong danh sách mong muốn của rất nhiều người. Kể cả tôi.\nKhông đặc điểm nào tự mình gánh nổi một sản phẩm. Nhưng hội tụ được vài cái — thật sự hữu ích, làm chắc chắn, đẹp mắt, hơi hiếm một chút, được những người bạn tin tưởng đứng ra bảo chứng — là bạn có những món người ta sẵn sàng xếp hàng để mua. Phần còn lại phần lớn là nhờ marketing.\n","date":"27/09/2023","description":"Vài quy luật tôi cứ thấy lặp lại mỗi khi nghĩ xem vì sao người ta lại muốn những thứ họ muốn.","externalUrl":null,"permalink":"/vi/posts/2023/2023-09-27-what-is-desirable/","section":"Bài viết","summary":"","title":"Điều gì khiến người ta khao khát một món đồ?","type":"posts"},{"content":"Công việc của tôi là làm một nền tảng nội dung, nên có một câu hỏi mà người dùng không bao giờ nhìn thấy, nhưng tôi thì nghĩ tới thường xuyên hơn hầu hết mọi người: đến lúc nào thì feed nên thôi hiển thị một bài viết?\nNội dung nào rồi cũng cũ. Người ta mở ứng dụng là để xem có gì mới, và feed phải đáp ứng được điều đó. Nhưng một bài hay từ tháng trước cũng không nên biến mất chỉ vì nó không còn mới. Hàm suy giảm (decay function) là cách tiêu chuẩn để mô hình hoá sự đánh đổi này, và nó đơn giản hơn cái tên nhiều.\nĐộ mới quan trọng, nhưng không phải ở đâu cũng vậy # Khi mở một trang tin tức hay một feed mạng xã hội, thường thì bạn muốn xem cái mới nhất. Ở những lĩnh vực thay đổi nhanh như tin tức hay thị trường tài chính, nội dung tuần trước chỉ tổ chiếm chỗ, nên cách xếp hạng ở đó nghiêng hẳn về độ mới.\nNhưng không phải lĩnh vực nào cũng thế. Bài viết học thuật hay các bài báo chuyên sâu vẫn giữ được giá trị theo thời gian, có khi còn quý hơn. Tuổi của nội dung có ý nghĩa trong hầu hết hệ thống xếp hạng, nhưng nên cho nó nặng bao nhiêu thì hoàn toàn tuỳ vào thứ bạn đang xếp hạng.\nMô hình hoá độ “phai” bằng suy giảm theo hàm mũ # Cách thường dùng để mô hình hoá độ liên quan giảm dần là suy giảm theo hàm mũ (exponential decay): nội dung càng cũ thì giá trị mà người ta cảm nhận được càng tụt nhanh. Dạng đơn giản nhất trông thế này:\nrelevance = e^(-decay_rate × age)\ntrong đó relevance là giá trị hiện tại của nội dung, decay rate quyết định nội dung mất giá trị nhanh hay chậm, còn age thường tính bằng số ngày kể từ lúc đăng.\nĐiều tôi thích ở mô hình này là nó chỉ có đúng một cái núm để vặn. Chỉnh decay rate là bạn quyết định được nội dung còn “sống” trong vài giờ, vài ngày hay vài tuần. Toàn bộ phần cấu hình chỉ có vậy.\nTương tác giúp nội dung hay sống lâu hơn # Tuổi của bài mới chỉ là một nửa bức tranh. Lượt xem, lượt thích và bình luận cho bạn biết người ta có thật sự quan tâm hay không; bỏ qua chúng thì cách xếp hạng sẽ chôn luôn cả bài hay lẫn bài dở. Cách sửa đơn giản là nhân hai yếu tố này với nhau:\nscore = engagement × e^(-decay_rate × age)\nGiờ thì một bài cũ nhưng tương tác mạnh vẫn có thể xếp trên một bài mới nhưng làng nhàng. Chỉ có điều, nó cần ngày càng nhiều tương tác hơn mới giữ được chỗ, vì phần suy giảm bên dưới cứ nhỏ dần đi.\nTrong thực tế, vòng đời của một bài viết diễn ra thế này: bài mới đăng được cộng điểm vì còn mới, nên có một cơ hội công bằng để được nhìn thấy. Nếu người ta xem, thích, bình luận, điểm sẽ tăng. Ngày qua ngày, phần suy giảm gặm dần vào điểm số, và chỉ những bài giữ được tương tác đều đặn mới trụ lại gần đầu feed. Nội dung mới thì được trao cơ hội; còn nội dung thật sự hay thì tự giành lấy chỗ đứng.\nNói thật thì mẹo chỉ có vậy. Một hàm mũ, một hệ số tương tác, một cái núm để vặn. Phần khó là chọn decay rate bao nhiêu cho hợp với loại nội dung của riêng bạn, và phần đó thì chỉ có thể dựa vào phán đoán.\n","date":"27/09/2023","description":"Hệ thống xếp hạng dùng hàm suy giảm để cân bằng giữa độ mới và mức tương tác như thế nào — góc nhìn của một người mà công việc hằng ngày là làm feed nội dung.","externalUrl":null,"permalink":"/vi/posts/2023/2023-09-27-simulating-views/","section":"Bài viết","summary":"","title":"Vì sao bài cũ cứ bị feed dìm xuống","type":"posts"},{"content":"Tôi vốn quen kể hết mọi chuyện cho cả đội. Phải hớ vài lần tôi mới vỡ lẽ rằng kiểu “nói hết, nói ngay, không lọc” chưa hẳn đã là minh bạch. Nửa số lần như thế, thực ra tôi chỉ đang trút nỗi lo của chính mình sang người khác. Câu hỏi khó chưa bao giờ là có nên cởi mở hay không, mà là cởi mở thế nào, và vào lúc nào.\nVì sao tôi vẫn mặc định cởi mở # Những lý lẽ quen thuộc ủng hộ minh bạch đều đúng cả, và tôi đã tận mắt thấy từng điều một. Khi hiểu lý do đằng sau một quyết định, người ta sẽ tin vào nó, kể cả khi không mấy thích. Khi nắm cùng thông tin với tôi, họ tự quyết đúng mà chẳng cần chờ tôi. Và chẳng ai vui khi phát hiện ra mình bị gạt ra ngoài một chuyện ảnh hưởng trực tiếp đến mình.\nMinh bạch kiểu “thô” dễ hỏng ở đâu # Chủ yếu là ba chỗ.\nKhối lượng. Cập nhật nào còn dở dang cũng chuyển tiếp hết thì những thứ thật sự quan trọng sẽ bị chôn vùi, và dần dần mọi người chẳng buồn để ý nữa.\nBối cảnh. Thông tin trần trụi mà thiếu câu chuyện đi kèm thì chỉ khiến người ta hoang mang. Cùng một câu “Mình đang xem lại roadmap”, có kèm lời giải thích thì nghe rất bình thường, còn không thì đáng sợ hơn nhiều.\nThời điểm. Chuyện còn chưa chốt mà đã nói ra thì cả đội sẽ mất cả tuần đoán già đoán non về những kịch bản vốn chưa bao giờ được tính đến. Một tin mới quyết được một nửa có khi còn ngốn nhiều năng lượng hơn chính quyết định cuối cùng.\nChờ không có nghĩa là giấu # Chọn thời điểm để chia sẻ nghe thì có vẻ như thao túng. Nhưng nếu làm thật lòng thì lại ngược hẳn: mục đích là để tin tức đến tay mọi người cùng với đủ bối cảnh để hiểu. Tin xấu dễ nghe hơn khi đi kèm một kế hoạch. Tin tốt cũng được đón nhận hơn khi không bị lẫn trong mớ thông báo dồn hết vào chiều thứ Sáu.\nNhưng chờ cũng có cái giá của nó, và cái giá ấy cứ thế cộng dồn. Ôm thông tin quá lâu thì người ta sẽ cảm nhận được. Bạn để trống chỗ nào, tin đồn sẽ lấp vào chỗ đó, mà tin đồn thì lúc nào cũng tệ hơn sự thật. Còn nếu một người lúc nào cũng là người biết sau cùng, họ sẽ hiểu đó là thông điệp về giá trị của mình trong đội. Mà cũng phải thôi — thường thì đúng là vậy thật.\nGiờ tôi quyết định thế nào # Trước khi chia sẻ (hay giữ lại) một chuyện nhạy cảm, tôi tự hỏi mình vài câu. Cả đội có cần thông tin này để làm việc không, hay chỉ là tôi muốn nói ra cho nhẹ lòng? Tôi đã giải thích được bối cảnh cho đàng hoàng chưa, hay mới chỉ đưa ra được mấy mảnh rời rạc? Và mọi người có khả năng sẽ đón nhận thế nào — tôi đã chuẩn bị sẵn câu trả lời cho những câu hỏi chắc chắn sẽ được hỏi chưa?\nRồi dù chọn hướng nào, tôi cũng cố làm ba việc: đưa bối cảnh đi kèm thông tin, để mọi người dễ hỏi lại sau đó, và để ý xem mọi chuyện diễn ra thế nào để lần sau căn chỉnh tốt hơn. Mỗi người muốn nghe chi tiết ở một mức khác nhau, và cách duy nhất để biết là quan sát và hỏi.\nMặc định là cởi mở, thêm bối cảnh, và dành một phút nghĩ xem người ta sẽ đón nhận thế nào trước khi bấm gửi. Đến giờ, chừng đó đã đủ cho hầu hết những tình huống tôi gặp.\n","date":"25/09/2023","description":"Tôi vốn quen cởi mở với cả đội, nhưng dần dần tôi nhận ra: minh bạch giúp ích hay gây hại là do bối cảnh và thời điểm quyết định.","externalUrl":null,"permalink":"/vi/posts/2023/2023-09-25-transperency/","section":"Bài viết","summary":"","title":"Khi nào nên minh bạch, khi nào nên chờ","type":"posts"},{"content":"Năm nay, lời khuyên quản lý nào tôi đọc cũng nói cùng một điều: hãy thấu cảm hơn. Được thôi. Nhưng chẳng lời khuyên nào cho bạn biết giới hạn nằm ở đâu.\nTôi đã phải tự tìm ra giới hạn đó theo cách khá vất vả. Tôi là một lập trình viên rồi tình cờ thành người dẫn dắt một đội dự án, chứ chưa từng được đào tạo bài bản cho vai trò này, nên hầu hết những gì dưới đây đều là bài học rút ra sau khi đã làm sai một chút.\nThấu cảm thực ra để làm gì # Thấu cảm ở vị trí lãnh đạo không phải là tỏ ra dễ chịu trong các buổi họp. Mà là để cảm xúc thật của mọi người thực sự được cân nhắc khi bạn ra quyết định, khi góp ý, hay khi quyết định cách đội làm việc. Khi mọi người cảm nhận được hoàn cảnh của mình được tính đến, họ thấy thoải mái hơn, dám lên tiếng sớm hơn, và công việc cũng thực sự tốt lên. Phần này của lời khuyên quen thuộc thì đúng.\nQuá ít thì hại. Quá nhiều cũng hại. # Kiểu thất bại mà ai cũng cảnh báo là người quản lý lạnh lùng, coi nhân viên như nguồn lực để phân bổ. Kiểu này có thật: nếu bạn phớt lờ những gì đang diễn ra trong cuộc sống của một người, họ sẽ thu mình lại, tinh thần cả đội đi xuống, và vấn đề nào bạn cũng biết muộn hàng tháng trời.\nCòn kiểu thất bại không ai cảnh báo thì ngược lại. Nếu chuyện gì bạn cũng chiều theo, ranh giới sẽ dần mờ đi. Hạn chót chỉ còn là lời gợi ý. Và những người vẫn âm thầm giữ chuẩn bắt đầu bực bội vì phải gánh thay cho những người không bị yêu cầu như vậy. Có những lúc tôi nhận ra mình đã chiều quá tay, vì lúc đó thấy làm vậy là tử tế. Nhưng với những người còn lại trong đội thì chẳng tử tế chút nào.\nCheck-in gánh phần lớn công việc # Thứ giúp tôi giữ được mức cân bằng tương đối không phải là triết lý gì cao siêu. Mà là một khung giờ cố định lặp lại trên lịch. Các buổi 1:1 đều đặn giúp những vấn đề nhỏ lộ ra khi vẫn còn nhỏ, thay vì dồn lại rồi ập đến một lúc dưới dạng lá đơn xin nghỉ việc.\nBuổi 1:1 của tôi thường xoay quanh vài chủ đề quen thuộc: điều gì đang cản trở bạn, gần đây có việc gì suôn sẻ, đội đang phối hợp với nhau thế nào, bạn muốn phát triển theo hướng nào, và tôi có thể làm gì khác đi.\nCâu hỏi hữu ích nhất mà tôi tìm được lại là một câu rất bình thường: \u0026ldquo;Bạn có cần gì từ mình hay từ đội để làm việc thuận lợi hơn không?\u0026rdquo; Nghe thì tầm thường, nhưng không hề tầm thường. Câu hỏi đó cho thấy tôi coi mình là một phần của giải pháp, và câu trả lời gần như lúc nào cũng là những thứ cụ thể mà tôi thực sự giải quyết được: kết nối với một người nào đó, chốt một quyết định đang bị treo, hay dành một tiếng ngồi pair cùng nhau.\nThẳng thắn khi câu trả lời là không # Check-in sẽ phát sinh đề xuất, và bạn không thể đáp ứng hết. Cách bạn xử lý những đề xuất không thể đáp ứng mới là chỗ niềm tin được vun đắp hoặc mất đi.\nCách tôi thấy hiệu quả là: nghiêm túc cân nhắc đề xuất và cho người kia biết là mình nghiêm túc, giải thích vì sao lúc này chưa làm được, đưa ra phương án thay thế một phần nếu có, hẹn khi nào sẽ xem xét lại, và rồi thực sự quay lại với nó, kể cả khi câu trả lời vẫn chưa đổi.\nLàm vậy cũng chẳng khiến việc nói \u0026ldquo;không\u0026rdquo; dễ chịu hơn. Nhưng người ta đón nhận một câu \u0026ldquo;không, và đây là lý do\u0026rdquo; thành thật tốt hơn nhiều so với một câu \u0026ldquo;để xem đã\u0026rdquo; mơ hồ rồi chẳng bao giờ nhắc lại.\nCông thức gần đúng nhất tôi có được là thế này: quan tâm đến chuyện mọi người đang thế nào, thành thật về những gì công việc đòi hỏi, và check-in đủ thường xuyên để nhận ra khi mình đã lệch quá xa về một phía.\n","date":"24/09/2023","description":"Thấu cảm thực sự giúp ích ở đâu khi bạn dẫn dắt một đội, khi nào thấu cảm quá mức bắt đầu gây rắc rối, và câu hỏi check-in đảm nhận gần hết mọi việc.","externalUrl":null,"permalink":"/vi/posts/2023/2023-09-24-empathy-in-management/","section":"Bài viết","summary":"","title":"Thấu cảm trong quản lý: bao nhiêu thì là quá?","type":"posts"},{"content":" Sự kiện của chúng tôi đã lên sóng KTV! 한글로 세계를 연결한다 / 한글의 가능성은 무한하다 Khi hai nền văn hóa gặp nhau # Chúng tôi phối hợp cùng Đại sứ quán New Zealand tại Seoul tổ chức Kiwi Alumni Night 2023. Sự kiện là dịp quy tụ những người từng du học ở New Zealand và hiện đang sống, làm việc tại Hàn Quốc.\nTình bạn # Buổi tối vừa là dịp hàn huyên với bạn bè cũ, vừa là dịp làm quen với những gương mặt mới, những người cũng có mối gắn bó với cả hai đất nước.\nTrong vai trò chủ tịch # Là người đứng ra tổ chức, thấy mọi người đến đông như vậy tôi thấy mọi công sức đều xứng đáng. Những người từng sống ở cả Hàn Quốc lẫn New Zealand luôn có một góc nhìn thú vị: họ đã trải qua cả hai nền văn hóa, và hiểu cách thu hẹp khoảng cách giữa hai bên.\nTrải nghiệm văn hóa # Chúng tôi còn được thưởng thức phần biểu diễn văn hóa của ba nghệ sĩ: Nghệ sĩ thư pháp Lucia Choi Nghệ sĩ gayageum (đàn tranh truyền thống Hàn Quốc) Siyoon Lee Nghệ sĩ bộ gõ Shinui Kang Ba nghệ sĩ đã hòa quyện văn hóa Hàn Quốc và New Zealand vào nhau thật tinh tế.\nĐội ngũ Kiwi Alumni # Đơn vị đồng hành # Kiwi Alumni Night 2023 thành công được là nhờ sự hỗ trợ của nhiều tổ chức và nhà tài trợ, trong đó có:\nĐại sứ quán New Zealand Cathay Pacific Kiwi Chamber Fonterra The University of Auckland Education New Zealand MOZZIE Doggo Dentist Queenstown - Coffee and Kitchen Bonnie’s Pizza Studio KJD (금준당) Spoiled Eun Pie ANZA Lucia Choi và Siyoon Lee Cảm ơn tất cả mọi người đã đến tham dự, cùng các nhà tài trợ và đơn vị đồng hành đã giúp Kiwi Alumni Night 2023 trở thành một đêm thật đáng nhớ. Một buổi tối rất vui, mong là sẽ còn dịp tổ chức lần nữa.\n","date":"22/09/2023","description":"Những khoảnh khắc đáng nhớ ở Kiwi Alumni Night 2023 tại Seoul, buổi tối tôn vinh sợi dây văn hóa giữa New Zealand và Hàn Quốc mà chúng tôi tổ chức cùng Đại sứ quán New Zealand.","externalUrl":null,"permalink":"/vi/posts/2023/2023-09-22-kiwi-alumni-my-role/","section":"Bài viết","summary":"","title":"Kiwi Alumni Night 2023","type":"posts"},{"content":"Có lần tôi lead một dự án đang bị chậm tiến độ. Tôi biết kiến trúc hệ thống có vấn đề. Tôi biết chúng tôi sẽ trễ hạn. Vậy mà tôi cứ ngồi chờ ai đó hỏi đến.\nBa tuần sau mới có người hỏi. Đến lúc đó thì mọi chuyện đã thành thảm họa.\nBài học tôi rút ra là: nếu cứ đợi người khác hỏi rồi mới nói, họ sẽ mặc định là mọi thứ vẫn ổn, cho đến khi mọi thứ không còn ổn nữa. Lúc đó họ sẽ thắc mắc sao bạn không nói sớm hơn.\nChỉ biết phản hồi thì có gì sai? # Hầu hết mọi người giao tiếp theo kiểu bị động. Có người gửi email thì trả lời. Có người hỏi tiến độ thì cập nhật. Có người báo lỗi thì sửa.\nNghe thì có vẻ hiệu quả. Bạn chẳng bao giờ làm mất thời gian của ai bằng những thông tin thừa.\nNhưng đồng thời, bạn cũng đang tập cho mọi người xung quanh thói quen phải chạy theo bạn mới có thông tin. Quản lý phải hỏi thì mới biết dự án đang thế nào. Các đội khác phải tự đi kiểm tra xem thay đổi của bạn có ảnh hưởng đến họ không. Ban lãnh đạo phải đào bới mới biết có rủi ro gì.\nNhiều kỹ sư giỏi tôi từng làm cùng mắc kẹt đúng ở chỗ này. Họ xây nên những hệ thống rất tốt nhưng chẳng ai biết, cho đến khi có thứ gì đó hỏng. Họ giải được những bài toán khó, nhưng chỉ sau khi đã có người phàn nàn.\nTôi từng chứng kiến một lập trình viên senior bỏ ra hai tuần để refactor một hệ thống thanh toán quan trọng. Mã viết rất đẹp. Thời gian xử lý giảm một nửa. Vậy mà chẳng ai biết, mãi sáu tháng sau mới có người tình cờ phát hiện ra trong lúc đang gỡ lỗi một chỗ khác. Bao nhiêu công sức, không ai nhìn thấy.\nTrong khi đó, một kỹ sư khác cùng đội thì cứ đưa được thứ gì lên production là gửi ngay một tin cập nhật ngắn: \u0026ldquo;Đã giảm latency của API 200ms, cách làm như sau.\u0026rdquo; Năm câu, có khi kèm một cái biểu đồ. Ai cũng biết người đó đang làm gì. Bạn đoán xem ai được thăng chức?\nKhác biệt không nằm ở kỹ năng. Mà nằm ở thời điểm lên tiếng.\nChuyện gì thực sự xảy ra khi bạn im lặng # Để tôi nói cụ thể \u0026ldquo;chờ người ta hỏi\u0026rdquo; trông như thế nào ngoài thực tế.\nBạn đã làm ba ngày cho một task ước tính hai ngày, và bạn biết mình còn cần thêm ba ngày nữa. Bạn sẽ nói ngay bây giờ, hay đợi đến buổi standup ngày mai khi có người hỏi?\nNếu bạn đợi, chuyện sẽ diễn ra thế này: quản lý của bạn đã lên kế hoạch với giả định là hôm nay bạn xong. Đội QA đã xếp lịch kiểm thử vào ngày mai. Product manager đã hứa với khách hàng là thứ Sáu sẽ phát hành. Giờ thì ai cũng cuống cuồng xoay xở lại, chỉ vì bạn không bỏ ra 30 giây để nói một câu \u0026ldquo;Việc này đang lâu hơn dự kiến.\u0026rdquo;\nHoặc thế này: bạn đang thay đổi cách hệ thống xác thực hoạt động. Bạn biết ứng dụng mobile đang ngầm giả định token có một định dạng nhất định. Bạn nhắn cho đội mobile ngay, hay đợi xem họ có tự phát hiện ra không?\nNếu bạn đợi, tuần sau họ deploy ứng dụng. Ứng dụng lỗi ngay trên production. Họ mất hàng giờ gỡ lỗi, rồi cuối cùng lần ra thay đổi của bạn. Thế là bạn thành người làm sập production mà không báo trước cho ai.\nChuyện kiểu này xảy ra suốt. Và lần nào cũng là vì có người biết mà không nói.\nThay vào đó nên làm gì # Hãy cho mọi người biết trước khi họ phải hỏi. Không phải chuyện gì cũng nói, chỉ những gì họ cần biết.\nDưới đây là những tình huống mà tôi đã học được là nên chủ động lên tiếng trước:\nKhi tiến độ bị trượt. Bạn ước tính năm ngày, giờ đã qua ba ngày mà mới xong chừng 30%. Nhắn ngay lúc đó: \u0026ldquo;Việc này lâu hơn mình nghĩ. Giờ ước chừng 8-10 ngày chứ không phải 5. Bạn muốn mình cắt bớt scope hay lùi hạn chót?\u0026rdquo;\nĐừng đợi standup. Đừng đợi ai hỏi. Biết lúc nào thì báo lúc đó.\nKhi bạn ra quyết định kỹ thuật. Bạn chọn Postgres thay vì MySQL. Bạn dùng REST thay vì GraphQL. Bạn thêm một field bắt buộc mới vào API.\nLúc quyết định thì có vẻ chỉ là chuyện trong phạm vi của bạn, nhưng thực ra đang có người khác phụ thuộc vào những quyết định đó. Hãy gửi một tin nhắn ngắn: \u0026ldquo;Báo trước nhé, mình sẽ đổi user endpoint để bắt buộc xác thực email. Thứ Năm này lên production. Có gì ảnh hưởng bên bạn thì báo mình nhé.\u0026rdquo;\nKhi bạn phát hiện rủi ro. Cơ sở dữ liệu đang phình to nhanh hơn dự kiến. API chậm dần khi tải tăng cao. Tính năng mới chạy được, nhưng mã lộn xộn và sẽ khó bảo trì.\nĐừng đợi đến khi nó thành vấn đề. Hãy lên tiếng khi vẫn còn thời gian xử lý: \u0026ldquo;Mình để ý response time của search endpoint đang tăng dần. Chưa gấp, nhưng chắc sprint tới mình nên tính chuyện thêm cache.\u0026rdquo;\nCập nhật định kỳ. Thứ Sáu nào tôi cũng gửi email cho các stakeholder. Mất chừng 10 phút. Ba gạch đầu dòng: tuần này đã phát hành được gì, tuần sau sẽ làm gì, và có điều gì tôi đang lo.\nHầu hết các tuần đều nhàm chán. Mà mục đích chính là vậy. Nhàm chán nghĩa là không có bất ngờ. Và khi thực sự có chuyện, họ đã nắm được bối cảnh vì tôi vẫn đều đặn cập nhật cho họ.\nVì sao người ta không làm vậy # Lý do nào tôi cũng từng nghe qua, vì chính tôi cũng đã dùng gần hết.\n\u0026ldquo;Tôi không muốn làm phiền mọi người.\u0026rdquo; Bạn không làm phiền họ đâu. Bạn đang giúp họ khỏi phải đi làm phiền bạn. Quản lý của bạn thà đọc một tin cập nhật hai câu, còn hơn phải xếp lịch họp chỉ để hỏi xem tình hình thế nào.\n\u0026ldquo;Lỡ tôi nói sai thì sao?\u0026rdquo; Thì gửi thêm một tin nữa. \u0026ldquo;Cập nhật: việc mình bảo mất 8 ngày hóa ra chỉ mất 6, tiến độ đã về lại đúng kế hoạch.\u0026rdquo; Chẳng ai giận vì tin tốt cả.\n\u0026ldquo;Tôi không muốn trông như người không biết mình đang làm gì.\u0026rdquo; Vấn đề là thế này: trễ hạn mà không báo trước mới khiến bạn trông như không biết mình đang làm gì. Còn báo sớm chuyện chậm tiến độ khiến bạn trông như người biết quản lý rủi ro.\n\u0026ldquo;Tôi bận quá.\u0026rdquo; Bạn không bận đến mức không gửi nổi một email ba câu. Còn nếu bạn nghĩ mình bận đến thế thật, thì bạn càng không có thời gian để dọn mớ hỗn độn xảy ra khi không gửi.\nLý do thật sự khiến người ta không lên tiếng sớm là vì nó khiến mình thấy khó chịu. Im lặng rồi cầu mong mọi chuyện ổn thỏa thì dễ hơn. Nhưng hy vọng không phải là kế hoạch, và im lặng không phải là chuyên nghiệp.\nKhi nào chỉ cần phản hồi # Bạn không thể báo trước mọi thứ. Có những tình huống cần phản ứng ngay, chứ không phải cảnh báo trước.\nKhi hệ thống gặp sự cố, hãy xử lý trước đã. Vẫn gửi cập nhật về những gì bạn đang làm, nhưng đừng đợi viết xong một bản post-mortem hoàn hảo rồi mới cầm máu.\nKhi ai đó đang bị kẹt vì chờ bạn code review, cứ review luôn đi. Họ không cần bản cập nhật hằng tuần của bạn, họ cần bạn gỡ kẹt cho họ trong vòng một tiếng tới.\nKhi có câu hỏi gấp trên Slack, hãy trả lời. Đừng bắt người ta đợi đến bản tóm tắt thứ Sáu.\nNguyên tắc chung: nhu cầu tức thời thì phản hồi ngay, còn lại thì chủ động báo trước.\nTrong thực tế thì thế nào # Đây là những gì đã thay đổi khi tôi bắt đầu làm điều này thường xuyên:\nÍt họp hơn. Khi mọi người đã biết chuyện gì đang diễn ra, họ không cần xếp lịch họp để hỏi nữa. Lịch họp của tôi từ hơn 25 tiếng mỗi tuần giảm xuống còn chừng 15 tiếng.\nÍt bất ngờ hơn. Khi bạn cảnh báo sớm, vấn đề không còn mang cảm giác của một vấn đề nữa, mà giống một việc bạn đang kiểm soát. Chẳng ai hoảng hốt vì một rủi ro bạn đã báo từ hai tuần trước.\nĐược tin tưởng hơn. Đây là thay đổi lớn nhất. Khi bạn đều đặn cho mọi người biết tình hình mà họ không cần phải hỏi, họ bắt đầu tin là bạn sẽ lo liệu được. Và thế là họ giao cho bạn nhiều trách nhiệm hơn, nhiều việc thú vị hơn.\nCòn vụ thăng chức tôi kể lúc nãy? Một phần là nhờ làm tốt. Nhưng phần lớn hơn là nhờ người đó đảm bảo đúng người biết đến những việc mình làm tốt, mà không phải đào bới mới thấy.\nBắt đầu từ đâu # Chọn một việc bạn đang làm trong tuần này. Trước khi có ai hỏi đến, hãy gửi một tin cập nhật ngắn.\nCó thể gửi cho quản lý: \u0026ldquo;Phần refactor API đang tiến triển tốt. Xong khoảng một nửa rồi, vẫn kịp cuối tuần.\u0026rdquo;\nCó thể gửi cho đội khác: \u0026ldquo;Mình vừa đổi cách xử lý user session. Chắc không ảnh hưởng gì bên bạn, nhưng nếu thấy phần auth có gì lạ thì báo mình nhé.\u0026rdquo;\nCó thể gửi cho đội mình: \u0026ldquo;Đã sửa xong lỗi trên production. Nguyên nhân gốc là race condition ở tầng cache. Mình đã thêm test để lỗi này không lặp lại nữa.\u0026rdquo;\nHai ba câu thôi. Rồi bấm gửi.\nXem chuyện gì xảy ra. Thường sẽ là một trong hai khả năng:\nHọ thấy trân trọng, còn bạn thì bớt lo vì không phải ôm thông tin một mình Họ không trả lời, cũng chẳng sao, vì giờ họ đã biết và bạn không cần bận tâm nữa Làm vài lần như vậy là nó thành phản xạ. Xong việc gì thì báo cho những người liên quan. Thấy vấn đề thì lên tiếng. Ra quyết định thì chia sẻ.\nSau vài tuần, người ta thôi hỏi bạn tình hình thế nào, vì họ đã biết cả rồi. Và đó là lúc mọi thứ bắt đầu dễ thở hơn.\n","date":"21/09/2023","description":"Có một pattern tôi thấy lặp đi lặp lại trong các đội: người thành công không phải lúc nào cũng là người giỏi nhất, mà là người chủ động lên tiếng trước.","externalUrl":null,"permalink":"/vi/posts/2023/2023-09-23-reactive-vs-proactive-communication/","section":"Bài viết","summary":"","title":"Hãy lên tiếng trước khi người khác phải hỏi","type":"posts"},{"content":"Trong hầu hết các dự án tôi từng tham gia, khâu chậm nhất không phải là viết mã. Mà là xếp hàng chờ: các quyết định chất đống sau lịch làm việc của một người quản lý, chờ một cái gật đầu mất ba ngày mới tới. Giao tiếp async và đội tự chủ là cách chữa quen thuộc, và tôi tin vào nó. Nhưng về bản chất, cuộc chuyển đổi này là bài toán niềm tin khoác áo bài toán công cụ.\nVì sao việc gì cũng phải chờ duyệt # Công bằng mà nói, văn hóa xin duyệt cũng có gốc gác của nó. Kiểm soát cho người ta cảm giác mọi thứ được chính xác: nếu quyết định nào cũng phải qua bàn quản lý thì chẳng có gì đi chệch hướng được. Một quyết định sai không chỉ làm hỏng dự án mà còn làm sứt mẻ uy tín của người quản lý, nên nỗi sợ chiếm phần rất lớn. Đôi khi người quản lý thật sự nghi ngờ đội chưa đủ kinh nghiệm để tự quyết cho tốt. Và đôi khi chỉ là thói quen còn sót lại: sơ đồ tổ chức xưa nay vẫn vận hành như vậy, thì cứ thế vận hành tiếp.\nĐể ý mà xem, trong bốn lý do đó chỉ có một lý do thực sự nằm ở phía đội. Ba lý do còn lại đều là chuyện của người quản lý.\nĐể tự chủ thì cần những gì # Tự chủ mà không cùng nhìn về một hướng thì chỉ là hỗn loạn, có điều tinh thần mọi người phấn chấn hơn. Khi ai cũng thực sự hiểu mục tiêu chung, những quyết định họ tự đưa ra phần lớn sẽ tự khắc cùng hướng. Bí quyết chỉ có vậy. Đào tạo cũng quan trọng, vì tin một người được trang bị đầy đủ thì dễ hơn tin một người chỉ có nhiệt huyết. Nên giữ lại vài ranh giới: có những quyết định thực sự nên để quản lý đưa ra, và nói rõ đó là những quyết định nào sẽ giúp tránh được những bất ngờ tệ nhất. Sau đó cứ để vòng lặp phản hồi vận hành, và coi những sai lầm không tránh khỏi là học phí chứ không phải bằng chứng để quy lỗi.\nNếu niềm tin đã sứt mẻ (mà ở những đội đang cố thoát khỏi văn hóa xin duyệt thì thường là đã sứt mẻ, từ cả hai phía) thì không có đường tắt nào cả. Hãy gọi tên vấn đề trong một buổi nói chuyện cởi mở, để phía quản lý nhận phần lỗi của mình trước, rồi kiên trì nhất quán đến nhàm chán trong nhiều tháng. Vai trò rõ ràng và những buổi check-in đều đặn hàn gắn được nhiều hơn mọi cử chỉ hoành tráng.\nPhần async mới là phần dễ # Nói qua về công cụ trước: Slack hay Microsoft Teams đều ổn cả. Việc thật sự cần làm là tài liệu. Khi không ai ngồi chung phòng hay chung múi giờ, những quyết định được viết ra chính là trí nhớ của cả đội. Hãy đặt quy ước về thời gian phản hồi để async không lặng lẽ biến thành \u0026ldquo;không ai trả lời\u0026rdquo;; \u0026ldquo;trong vòng một ngày\u0026rdquo; tốt hơn nhiều so với \u0026ldquo;lúc nào rảnh\u0026rdquo;. Và hãy thực sự dạy mọi người cách viết một tin cập nhật async cho tử tế, vì kỹ năng này không tự nhiên mà có: đủ bối cảnh để người đọc ra quyết định, nói rõ cần quyết định điều gì, và hạn chót là khi nào.\nBạn có thể triển khai Slack trong một buổi chiều. Còn niềm tin thì phải mất hàng tháng, và chính nó quyết định mọi thứ ở đây có thành hay không. Hãy làm rõ mục tiêu, và để một sai lầm chỉ tốn một bài học chứ không tốn cả sự nghiệp của ai. Làm được vậy thì phần async gần như tự vận hành.\n","date":"20/09/2023","description":"Vì sao các đội rốt cuộc việc gì cũng phải xin phép, và cần những gì (chủ yếu là niềm tin) để chuyển sang giao tiếp async và để đội tự ra quyết định.","externalUrl":null,"permalink":"/vi/posts/2023/2023-09-20-async-and-auto-flow/","section":"Bài viết","summary":"","title":"Để giao tiếp async và đội tự chủ thực sự vận hành được","type":"posts"},{"content":"Lập trình từng là sở thích của tôi. Đến một lúc nào đó hồi đại học, nó lặng lẽ trở thành nghề, và từ đó đến nay tôi đã có nhiều năm để nhận ra bước chuyển ấy thay đổi một thứ mình yêu thích như thế nào. Giờ thì nhiếp ảnh mới là sở thích của tôi, và thỉnh thoảng lại có người gợi ý tôi bán ảnh in hoặc nhận chụp sự kiện để kiếm thêm. Lần nào tôi cũng từ chối, và bài viết này đại khái là lý do.\nNgười ta hay nói: \u0026ldquo;Hãy làm điều bạn yêu thích, và bạn sẽ không phải làm việc ngày nào trong đời.\u0026rdquo; Tôi hiểu vì sao câu này nghe hấp dẫn, nhưng tôi nghĩ nó nói ngược. Đem điều mình yêu thích ra kiếm tiền, và bạn sẽ khám phá ra đủ kiểu mới toanh để nó trở nên y hệt đi làm.\nSở thích thực sự cho bạn điều gì # Giá trị của một sở thích chủ yếu nằm ở những thứ nó không có. Không hạn chót, không khách hàng, không chỉ tiêu phải đạt. Tôi chụp ảnh theo nhịp của riêng mình, chụp bất cứ thứ gì mình thích, và nếu chụp cả một cuối tuần mà toàn ra ảnh làng nhàng thì cũng chẳng ai bận tâm, kể cả tôi, hầu như là vậy. Niềm vui nằm ở chính quá trình, và tiền thuê nhà chẳng hề phụ thuộc vào nó. Chính vì không có gì để mất nên những lần tìm tòi, thử nghiệm mới thật lòng.\nTiền thay đổi điều gì # Ngay khi sở thích bắt đầu ra tiền, luật chơi thay đổi. Hạn chót và yêu cầu của khách hàng đến trước tiên. Rồi đến áp lực phải giữ chất lượng đều tay, vì khi đã có người trả tiền thì bạn không được phép có một tháng phong độ kém. Rồi thị trường bắt đầu nhúng tay vào chính tác phẩm: bạn dần trôi về phía những gì bán được, mà hiếm khi đó lại là thứ đã khiến bạn say mê lúc ban đầu. Cách bạn đón nhận lời chê cũng đổi khác. Một lời chê gay gắt về dự án làm cho vui chỉ khiến bạn khó chịu một buổi tối; còn một lời chê gay gắt nhắm vào cần câu cơm thì đeo bám bạn mãi.\nNói vậy không có nghĩa là làm chuyên nghiệp là sai. Chỉ là sở thích bạn từng yêu và cái nghề mà nó trở thành không còn là cùng một hoạt động nữa, dù nhìn từ bên ngoài thì chúng giống hệt nhau.\nGiữ lại một phần niềm vui # Tôi vẫn lập trình cho vui, và bí quyết là mã viết cho vui thì không dính dáng gì đến công việc. Chẳng ai trả tiền nên tôi có thể bỏ dở một side project giữa chừng mà không thấy áy náy. Và những lúc lập trình ở công ty trở nên nặng nề, thường chính một dự án cá nhân vô bổ nào đó lại nhắc tôi nhớ vì sao mình bắt đầu. Còn nhiếp ảnh thì tôi giữ hẳn ở bên kia lằn ranh: nó không kiếm ra đồng nào, cũng không nợ ai điều gì, và giá trị của nó nằm chính ở đó. Nếu sở thích của bạn thay đổi theo năm tháng, cứ để nó thay đổi. Sở thích đã thành nghề đâu nhất thiết phải là sở thích duy nhất của bạn.\nVậy nên, đúng là bạn hoàn toàn có thể biến thứ mình yêu thành kế sinh nhai. Nhiều người trong chúng ta đã làm vậy, và tôi không hối hận. Chỉ cần biết rằng một khi thứ đó còn phải lo cả các hóa đơn cho bạn, thì giữ cho tình yêu với nó không nguội đi cũng trở thành một công việc nhỏ riêng.\n","date":"05/09/2023","description":"Kiếm tiền từ sở thích nghe thật tuyệt, cho đến khi hạn chót, yêu cầu của khách hàng và áp lực thị trường khiến bạn thấy khác đi về chính thứ mình từng yêu thích.","externalUrl":null,"permalink":"/vi/posts/2023/2023-09-05-hobby-vs-profession/","section":"Bài viết","summary":"","title":"Chuyện gì xảy ra khi biến sở thích thành công việc","type":"posts"},{"content":"Tôi đã ngồi trong không ít buổi retrospective mà mọi người trao đổi rất thật lòng, rất hay, nhưng rốt cuộc chẳng có gì thay đổi. Vấn đề chưa bao giờ nằm ở format, mà nằm ở khâu theo sát đến cùng. Dạo này tôi đang phụ trách các buổi retro của đội mình, nên đây là những gì tôi rút ra được.\nBuổi họp là phần dễ # Cấu trúc đơn giản đến mức gần như chẳng có gì để nói. Sau mỗi sprint hoặc mỗi giai đoạn dự án, cả đội ngồi lại với nhau và lần lượt trả lời ba câu hỏi:\nĐiều gì đã làm tốt? Điều gì chưa ổn? Mình có thể cải thiện gì? Chỉ vậy thôi. Mấu chốt nằm ở vòng lặp phản hồi: đều đặn dừng lại, nhìn vào những gì đã thực sự diễn ra, rồi đưa những điều đó quay lại vào cách mình làm việc. Một đội không bao giờ chịu dừng lại thì sẽ chỉ lặp lại chính mình.\nRetro có ích khác buổi xả bực ở chỗ nào # Chủ yếu là ở sự thẳng thắn. Người ta chỉ dám nói ra vấn đề thật khi tin chắc rằng mình sẽ không bị làm khó vì điều đó, nên cảm giác an toàn trong phòng quan trọng hơn bất kỳ format nào bạn chọn. Và những nhận xét sắc sảo nhất thường đến từ những người ít nói. Một buổi retro mà chỉ có mỗi lead nói thì thực chất là một buổi họp báo cáo tình hình, chỉ là rườm rà hơn.\nNửa còn lại là những gì diễn ra sau buổi thảo luận. Vấn đề nào đáng sửa thì trước khi ra khỏi phòng phải trở thành một action item, có tên người phụ trách và hạn chót rõ ràng. Rồi đến buổi retro tiếp theo, hãy mở đầu bằng cách xem lại các action item lần trước. Đây là bước mà hầu hết các đội đều bỏ qua. Không gì giết chết retro nhanh bằng việc mọi người lặng lẽ nhận ra mấy cái action item chỉ để trang trí.\nKhi có thể, hãy mang cả số liệu vào. Những ý kiến kiểu \u0026ldquo;sprint này có vẻ chậm\u0026rdquo; cứ bàn qua bàn lại mãi không đi đến đâu, trong khi một cái biểu đồ thường chốt được vấn đề ngay.\nNhững kiểu retro thất bại # Có ba kiểu tôi từng tận mắt chứng kiến. Một là retro biến thành buổi đổ lỗi (hãy sửa quy trình, đừng nhắm vào con người). Hai là retro dùng y nguyên một format suốt cả năm, đến mức ai cũng tham gia như cái máy (hãy thay đổi cách làm). Ba là retro xong rồi thôi, chẳng có follow-up gì cả. Như đã nói ở trên, kiểu thứ ba mới là kiểu chí mạng.\nNhân tiện, những điều này không chỉ áp dụng cho phần mềm. Chiến dịch marketing, tổ chức sự kiện, dự án nghiên cứu, sản xuất: bất cứ công việc nào có chu kỳ lặp lại đều hưởng lợi từ cùng một vòng lặp này. Nhưng dù bạn làm retro ở đâu thì phép thử vẫn vậy: trước buổi tiếp theo, có điều gì thực sự thay đổi không? Nếu không, thì bạn chỉ đang nói suông mà thôi.\n","date":"01/09/2023","description":"Những gì tôi học được khi tổ chức retro cho đội: buổi họp chỉ là phần dễ, làm đến nơi đến chốn sau đó mới là thứ quyết định.","externalUrl":null,"permalink":"/vi/posts/2023/2023-09-01-retrospecs/","section":"Bài viết","summary":"","title":"Làm retro sao cho thực sự tạo ra thay đổi","type":"posts"},{"content":"Những lỗi đáng xấu hổ nhất mà tôi từng để lọt ra production đều có chung một điểm: bộ test lúc đó xanh hết. Mã chạy đúng y như spec, chỉ có điều spec lại không khớp với thứ người dùng thực sự cần. User acceptance testing (UAT, kiểm thử chấp nhận người dùng) sinh ra để lấp đúng khoảng trống đó. Đây là giai đoạn người dùng thật ngồi xuống dùng thử phần mềm trước khi ra mắt, để trả lời câu hỏi mà unit test hay đội QA không trả lời được: sản phẩm này có thực sự dùng được với chính những người sẽ dùng nó hằng ngày không?\nVì sao tôi không thể tự kiểm thử tính năng của mình # Đến lúc một tính năng làm xong, tôi đã đi qua happy path của nó cả trăm lần. Tôi biết phải bấm nút nào, vì chính tay tôi làm ra cái nút đó. Chính sự quen thuộc ấy khiến tôi thành người kiểm thử tệ nhất có thể. Nó cũng là lý do QA chỉ giải quyết được một nửa vấn đề, vì QA vẫn kiểm thử dựa trên spec. UAT là giai đoạn duy nhất mà câu hỏi đổi từ \u0026ldquo;có chạy đúng như thiết kế không?\u0026rdquo; sang \u0026ldquo;thiết kế như vậy đã đúng chưa?\u0026rdquo;\nMột vòng UAT diễn ra thế nào # Việc đầu tiên là lên kế hoạch: phạm vi, tiến độ, ai tham gia, và thế nào thì được tính là \u0026ldquo;đạt\u0026rdquo;. Tiêu chí thành công mà mơ hồ thì vòng UAT sẽ cứ kéo dài hết tuần này sang tuần khác.\nTiếp theo là chọn những người giống với người dùng thật của bạn. Đồng nghiệp không dính dáng gì đến quá trình làm ra tính năng là lựa chọn tốt; một nhóm nhỏ stakeholder bên ngoài, sẵn lòng hợp tác và hiểu sản phẩm làm ra để giải quyết việc gì, thì còn tốt hơn nữa. Hãy viết kịch bản xoay quanh công việc hằng ngày thay vì các edge case, vì QA đã xử kỹ mấy edge case đó rồi. Một kịch bản UAT tốt đọc lên giống một ngày làm việc bình thường như bao ngày khác, chứ không phải một bài stress test.\nTrong vòng kiểm thử, người dùng lần lượt đi qua từng kịch bản. Bất cứ chỗ nào không khớp với điều họ mong đợi đều được ghi lại thành defect, kể cả những chỗ về mặt kỹ thuật là \u0026ldquo;chạy đúng như thiết kế\u0026rdquo;. Kết thúc vòng thì tổng hợp góp ý, sửa những gì cần sửa, và nếu phải thay đổi nhiều thì chạy thêm một vòng nữa, đừng mặc định là mọi thứ đã ổn. Sau cùng là lấy sign-off đàng hoàng, đối chiếu với các tiêu chí đã thống nhất từ đầu.\nUAT mang lại cho bạn điều gì # Chủ yếu là bắt được vấn đề khi sửa vẫn còn rẻ: ngay trước khi ra mắt, thay vì phải tung bản vá kèm lời xin lỗi sau đó. Ngoài ra còn vài cái lợi ít ai để ý hơn. Việc đào tạo và hỗ trợ người dùng nhẹ nhàng hơn, vì các luồng thao tác đã khớp với cách mọi người thực sự làm việc. Và những stakeholder từng tham gia kiểm thử sẽ sign-off nhanh hơn, vì đến lúc đó sản phẩm cũng phần nào là của họ.\nỞ công ty, tôi để ý thấy những bản phát hành có một vòng UAT tử tế thường là những lần phát hành êm ả nhất. Người ta dễ coi UAT là một ô cần tích cho xong ở cuối sprint. Tốt hơn hết là coi nó như cửa ải cuối cùng: khoảnh khắc cuối trước khi ra mắt, khi có một người không phải bạn thử dùng thứ bạn làm ra.\n","date":"27/08/2023","description":"Ghi chép về cách chạy UAT sao cho thực sự phát hiện ra vấn đề: nên mời ai, viết kịch bản kiểm thử thế nào, và khi nào thì coi là xong.","externalUrl":null,"permalink":"/vi/posts/2023/2023-08-27-user-acceptance-testing/","section":"Bài viết","summary":"","title":"Cách tôi tổ chức kiểm thử chấp nhận người dùng (UAT)","type":"posts"},{"content":"Gần đây tôi có dự một buổi talk show của Stone Island ở Hàn Quốc. Tôi cứ nghĩ sẽ phải ngồi nghe cả tiếng đồng hồ tung hô thương hiệu, nhưng hóa ra nó giống cảnh một công ty điềm tĩnh giải thích cách mình suy nghĩ hơn. Nghe một hồi tôi lại ghi chép khá nhiều, nên chia sẻ lại ở đây.\nChiếc badge tự nói lên gần hết\nTrong làng thời trang, khó có biểu tượng nào giống như chiếc badge của Stone Island, và chính thương hiệu cũng biết rõ điều đó. Có một chi tiết tôi chưa từng biết: badge được đặt ở vị trí gần tim nhất. Nghe thì giống chiêu marketing, cho đến khi một nhà thiết kế nói điều đó với bạn bằng vẻ mặt hoàn toàn nghiêm túc. Nếu thích kín đáo, bạn cũng có thể tháo nó ra.\nThật ra chiếc badge giống một tấm thẻ hội viên. Mặc đồ Stone Island không hẳn là chuyện mặc một món đồ, mà là gia nhập một câu lạc bộ: cảm giác được thuộc về đến trước, chiếc áo khoác đến sau. Toàn bộ mô hình kinh doanh của họ nằm gọn trong đó.\nNhững chi tiết mà tự tôi sẽ chẳng bao giờ để ý\nKhi đàn ông thử áo khoác, họ sẽ kiểm tra túi áo trước rồi mới nhìn vào gương. Stone Island thiết kế dựa đúng trên trình tự đó. Cả đời tôi vẫn làm theo đúng thứ tự ấy mà chưa lần nào nghĩ tới.\nPhom dáng của họ mặc lên người phụ nữ cũng đẹp không kém. Họ đã làm những thiết kế phi giới tính đúng nghĩa từ trước khi chủ đề này trở thành trào lưu.\nCâu chuyện hay nhất tối hôm đó là lá thư của một nhà sưu tầm ở Milan, người đã tự trang trải chuyện học đại học bằng cách bán dần bộ sưu tập Stone Island của mình. Còn mối gắn bó với Hàn Quốc thì sâu hơn tôi tưởng: thương hiệu đã có mặt ở đây 25 năm, và các diễn giả nhắc đến những nhà sưu tầm Hàn Quốc như bạn bè chứ không phải khách hàng. Theo họ, Ý và Hàn Quốc có nhiều điểm tương đồng về văn hóa hơn người ta nghĩ: âm nhạc, opera, bóng đá.\nChất liệu là trên hết\nHóa ra cái tên Stone Island được ghép từ hai từ rất bình thường nhặt ra từ sách: stone (đá) và island (đảo). Không nghiên cứu gì, cũng chẳng đắn đo gì nhiều. Tôi khá thích điều đó.\nCác thiết kế là sản phẩm của cả một đội ngũ bài bản, gồm những người đến từ khắp nơi, chứ không phải của một thiên tài đơn độc nào. Nhưng thứ mà buổi nói chuyện cứ quay lại mãi là chất liệu vải: thành phần gồm những gì, ăn thuốc nhuộm ra sao, có thể xử lý theo những cách nào, co giãn thế nào. Họ gọi đó là làm nghiên cứu \u0026ldquo;thật\u0026rdquo;: làm thí nghiệm, làm sai, và thỉnh thoảng một sai lầm lại trở thành thành công của mùa sau. Nghe nói đợt nghiên cứu tiếp theo sẽ đi theo hướng xanh: dùng vật liệu tái chế và xem xét nghiêm túc lượng nước tiêu thụ.\nBài học tôi rút ra nghe thì nhạt nhưng rất thực tế: hãy ám ảnh với chất liệu, và nhất quán đến mức nhàm chán với bản sắc của mình, suốt hàng chục năm. Cách này hiệu quả với áo khoác. Tôi đoán nó cũng hiệu quả với phần mềm.\n","date":"04/08/2023","description":"Ghi chép từ một buổi talk show của Stone Island ở Hàn Quốc: cách thương hiệu giữ bản sắc nhất quán, chuyện nghiên cứu chất liệu vải, cộng đồng sưu tầm ở Hàn Quốc, và điều gì làm nên thành công của thương hiệu này.","externalUrl":null,"permalink":"/vi/posts/2023/2023-08-04-stone-island/","section":"Bài viết","summary":"","title":"Ghi chép từ một buổi talk show của Stone Island","type":"posts"},{"content":"Chẳng ai thích bị hỏi \u0026ldquo;việc đến đâu rồi?\u0026rdquo;, nhưng đã là người lead dự án ở công ty thì tôi vẫn phải nắm được tình hình. Cách dung hòa của tôi là git graph: trước khi hỏi ai điều gì, tôi mở nó ra xem trước, và phần lớn thời gian câu trả lời đã nằm sẵn ở đó. Chỉ cần liếc qua là thấy được hoạt động commit, các nhánh được tạo ra và merge vào ra sao, mà chẳng ai phải ngồi họp vì chuyện đó.\nGraph thực sự nói lên điều gì # Nếu bạn chưa từng nhìn kỹ một cái graph: mỗi node là một commit, mỗi đường tách ra là một nhánh, và cả sơ đồ đọc lên giống như dòng thời gian của dự án. Cách trình bày này chứa nhiều thông tin hơn bạn tưởng:\nLiếc một cái là biết đang có bao nhiêu nhánh hoạt động và commit được đẩy lên đều đặn cỡ nào. Một nhánh bỗng dưng im ắng, hay một nhánh mấy tuần rồi chưa merge, thường có nghĩa là ai đó đang bị kẹt. Đây là tín hiệu cảnh báo sớm rất đáng giá. Tần suất merge cho bạn biết cả đội đang phối hợp với nhau thế nào. Merge thường xuyên nghĩa là phần việc của mọi người thực sự đang được ghép lại với nhau; còn nhánh tồn tại quá lâu thường là dấu hiệu có người đang làm một mình một cõi. Ít làm phiền hơn, trao đổi chất lượng hơn # Cái lợi dễ thấy nhất là tôi không phải làm phiền ai mới nắm được tình hình. Nhưng cái lợi lớn hơn nằm ở những lúc thật sự cần nói chuyện: cuộc trao đổi bắt đầu từ những gì graph cho thấy, chứ không phải từ phỏng đoán. \u0026ldquo;Nhánh này đứng yên từ thứ Ba rồi, có gì đang vướng không?\u0026rdquo; là câu mở đầu tốt hơn nhiều so với \u0026ldquo;có gì mới không?\u0026rdquo;\nNói rõ thói quen này cho mọi người biết cũng giúp ích. Tôi thường liếc nhanh vào hầu hết các buổi sáng, và xem kỹ mỗi tuần một lần. Cả đội đều biết nhịp đó, nên không ai bị bất ngờ.\nĐừng biến nó thành công cụ theo dõi nhân viên # Đây là điều tôi phải tự nhắc mình hoài. Số lượng commit không đồng nghĩa với giá trị: một commit chỉn chu có khi đáng giá hơn năm mươi commit lặt vặt. Thấy được mọi commit không có nghĩa là bạn có quyền chất vấn mọi quyết định. Hãy tin đội của mình, và chỉ nhảy vào khi thấy một pattern thật sự có vấn đề. Còn khi đem graph ra trong một buổi trao đổi, hãy dùng nó làm điểm khởi đầu để góp ý, chứ không phải làm bằng chứng để buộc tội ai.\nCông cụ để xem graph # Có rất nhiều lựa chọn, phần lớn đều miễn phí:\ngit log --graph --all --oneline: xem graph dạng văn bản ngay trong terminal, không cần cài thêm gì. Gitk: GUI có sẵn của Git để duyệt lịch sử commit. SourceTree: GUI miễn phí của Atlassian, công ty đứng sau Bitbucket. GitKraken: đẹp nhất trong số này, phần hiển thị graph rất trực quan. TortoiseGit: dành cho người dùng Windows, tích hợp thẳng vào menu chuột phải và có graph lịch sử. GitHub, GitLab và Bitbucket: đều có sẵn chế độ xem \u0026ldquo;network\u0026rdquo; hoặc \u0026ldquo;graph\u0026rdquo; ngay trong trang repo. Extension Git Graph cho VS Code: graph tương tác mà không cần rời khỏi editor. Tower: thêm một Git client chắc tay nữa, có graph trực quan. Chọn công cụ nào cũng được, mục tiêu vẫn chỉ có một: hiểu tiến độ, chứ không phải đi đếm commit.\n","date":"02/06/2023","description":"Cách tôi dùng git graph để nắm tiến độ dự án mà không phải liên tục hỏi cả đội cập nhật tình hình.","externalUrl":null,"permalink":"/vi/posts/2023/2023-06-02-project-progress-git-graph/","section":"Bài viết","summary":"","title":"Dùng git graph để theo dõi tiến độ mà không cần micromanage","type":"posts"},{"content":"Phần lớn những dự án rối như canh hẹ mà tôi từng bị kéo vào đều không phải do mã nguồn tệ. Nguyên nhân là thông tin cần thiết không đến được đúng người, cho tới khi đã quá muộn. Từ khi nhận vai trò project lead ở công ty, tôi bắt đầu ghi lại cách mình muốn điều phối giao tiếp, nếu có ngày ngồi vào ghế CTO. Đây là những ghi chép đó.\nXác định ai cần biết gì # Nền móng thì chẳng có gì hào nhoáng: một kế hoạch giao tiếp ghi rõ ai cần được báo, họ cần biết gì, bao lâu báo một lần và qua kênh nào. Tôi sẽ đòi có kế hoạch này ngay từ đầu mỗi dự án, kèm tên người phụ trách cụ thể, vì việc gì cũng giao cho \u0026ldquo;cả nhóm\u0026rdquo; thì rốt cuộc chẳng ai làm. Với stakeholder cũng vậy: tìm ra ai thực sự quan tâm đến dự án, hiểu mỗi người thích được cập nhật theo cách nào, và đừng gửi cho CEO nguyên cái tin cập nhật bạn vừa đăng trong kênh dev. Cuối cùng là đặt ra vài quy ước rõ ràng: bao lâu thì nên phản hồi, thế nào mới tính là gấp, kênh nào dùng cho việc gì. Phần lớn cái gọi là \u0026ldquo;vấn đề giao tiếp\u0026rdquo; thật ra chỉ là những kỳ vọng chưa ai nói ra thành lời.\nCông cụ và nhịp làm việc # Công cụ không quan trọng bằng mức người ta hay cãi nhau về nó. Slack để nhắn tin, Trello để quản lý task, Zoom khi cần nhìn mặt nhau mà nói chuyện. Chọn cái hợp với đội mình rồi đi tiếp. Nhịp làm việc mới quan trọng hơn: daily stand-up nếu bạn làm agile, cập nhật hằng tuần nếu chu kỳ dài hơn, và quan trọng là giữ đúng nhịp đó. Rồi hãy ghi chép lại mọi thứ. Quyết định, thay đổi, action item, tất cả đều đưa vào Notion hay Jira, như một phần của quy trình chứ không phải chuyện tiện thì làm. Sáu tháng sau, tài liệu là thứ duy nhất còn nhớ chuyện gì đã xảy ra.\nPhần \u0026ldquo;mềm\u0026rdquo; mới là phần khó # Tất cả những gì ở trên chỉ là cơ chế. Việc khó hơn là hỏi cả đội xem cách giao tiếp hiện tại có thực sự hiệu quả không, và thay đổi khi nó không hiệu quả. Rồi còn chuyện đào tạo nữa: mỗi người trong đội đến từ một nền tảng khác nhau, và không phải ai cũng từng làm trong một dự án được vận hành tử tế. Còn phải biết lắng nghe cho đàng hoàng thay vì chỉ ngồi chờ đến lượt mình nói, và xử lý mâu thuẫn từ sớm, khi nó còn nhỏ. Mà chẳng có gì trong số này giải quyết một lần là xong: cách làm hiệu quả ở quý trước có thể lặng lẽ hết tác dụng, nên cứ phải kiểm tra lại thường xuyên.\nLàm việc trong một đội phát triển phân tán/bất đồng bộ # Nếu đội của bạn làm remote hoặc chủ yếu làm việc async, bài nói này rất đáng để bạn bỏ thời gian ra xem: Tôi không làm hoàn hảo được hết những điều này, mà cũng chẳng ai làm được. Nhưng nhìn lại thì dự án nào suôn sẻ cũng có phần lớn những thứ trên, còn dự án nào khiến tôi khổ sở thì đều thiếu ít nhất ba thứ.\n","date":"02/06/2023","description":"Những ghi chép tôi đang gom lại về cách điều phối giao tiếp trong dự án cho tốt, phòng khi một ngày nào đó tôi ngồi vào ghế CTO.","externalUrl":null,"permalink":"/vi/posts/2023/2023-06-02-project-frameworks-for-future-cto/","section":"Bài viết","summary":"","title":"Ghi chép về giao tiếp trong dự án, viết cho tôi của sau này khi làm CTO","type":"posts"},{"content":"Ở chỗ làm tôi nghĩ về chuyện này khá nhiều, vì sản phẩm tôi đang xây là một ứng dụng cộng đồng, sống dưới cái bóng của những mạng xã hội lớn hơn mình rất nhiều. Câu hỏi đặt ra là: nên dựa vào các ông lớn để phát triển, hay cạnh tranh trực diện với họ? Đi đường nào cũng có người thành công. Điều đáng bàn hơn là khi nào thì nên chuyển từ đường này sang đường kia.\nVì sao nên cùng tồn tại trước # Ở giai đoạn đầu, khi bạn vẫn đang loay hoay tìm product-market fit, bám vào các nền tảng lớn thường là lựa chọn đúng. Hoạt động bên trong một hệ sinh thái đã có sẵn người dùng giúp bạn học được người ta thực sự muốn gì. Tích hợp với các nền tảng phổ biến cho bạn một kênh tiếp cận người dùng mà có tiền cũng khó mua được. Bạn cũng có thể chọn cho mình một thị trường ngách, thay vì lao vào đấu tay đôi với người khổng lồ. PayPal khởi đầu chỉ là một phương thức thanh toán cho người dùng eBay, và cứ kể tên các ứng dụng thành công thì có đến một nửa ban đầu chỉ là add-on cho Shopify hay Slack.\nKhi nào nên cạnh tranh # Bước chuyển thường đến muộn hơn, khi bạn đã hiểu người dùng của mình còn rõ hơn chính nền tảng kia. Instagram là ví dụ kinh điển: từ một ứng dụng chỉnh filter ảnh, họ lớn dần thành một mạng xã hội thực thụ. Có hai dấu hiệu tôi sẽ để ý. Một là bạn đã tích lũy được chuyên môn thật sự trong lĩnh vực của mình, hoặc có công nghệ của riêng mình. Hai là nhìn vào hành vi người dùng, bạn thấy rõ họ đang cần thứ mà các ông lớn hiện tại không đáp ứng. Nếu chưa có dấu hiệu nào, cạnh tranh lúc này chỉ là tự lao vào một trận chắc chắn thua.\nVới một ứng dụng cộng đồng thì cụ thể là gì # Với sản phẩm cộng đồng, cùng tồn tại phần lớn có nghĩa là làm tích hợp, và việc này kém hào nhoáng hơn nhiều so với tên gọi. Đó là đăng nhập bằng tài khoản mạng xã hội, để người dùng không phải tạo thêm một tài khoản nữa mới tham gia được. Là giúp mọi người chia sẻ nội dung cộng đồng lên trang cá nhân thật dễ dàng, vì chính đó là ngân sách marketing của bạn. Là gửi thông báo khi ai đó được nhắc tên hay được khen trên một nền tảng khác, làm embed và widget để đối tác đưa tính năng cộng đồng lên trang web của họ, và kết nối API với các dịch vụ bổ trợ. Về mặt phi kỹ thuật, hợp tác với những influencer vốn đã \u0026ldquo;sống\u0026rdquo; trên các nền tảng lớn, hay chạy chiến dịch trên nhiều nền tảng cùng lúc, thường kéo tương tác lên hiệu quả hơn bất cứ thứ gì bạn tự xây.\nChuyện này không phải trắng đen rạch ròi. Phần lớn startup bắt đầu bằng cách cùng tồn tại với các ông lớn, rồi lớn dần và dịch sang cạnh tranh. Cái khó là nhận ra đúng lúc mình đã âm thầm đủ sức để bước sang giai đoạn đó.\n","date":"12/04/2023","description":"Vài suy nghĩ về chuyện khi nào startup nên xây sản phẩm dựa trên các nền tảng lớn, và khi nào là lúc đứng ra đối đầu với họ.","externalUrl":null,"permalink":"/vi/posts/2023/2023-04-12-coexisting-stategy/","section":"Bài viết","summary":"","title":"Startup nên cùng tồn tại hay cạnh tranh với các ông lớn?","type":"posts"},{"content":"Số lần tôi góp ý rồi hỏng theo đúng một kịch bản nhiều hơn tôi muốn thừa nhận: tôi vừa nêu ra một chuyện, người kia lập tức gồng lên, và từ giây phút đó cả hai chẳng ai thật sự nghe ai nữa. Phòng thủ là phản ứng rất bình thường khi con người cảm thấy bị đe dọa, nhưng một khi nó đã trỗi dậy thì nói gì cũng không lọt tai. Dưới đây là những gì tôi rút ra được về cách làm việc cùng phản ứng đó, thay vì cố đè nó xuống.\nĐừng để mình bị cuốn theo # Khi ai đó bắt đầu phòng thủ, phản xạ tự nhiên của tôi là phòng thủ lại. Và đó chính là cách chắc chắn nhất để cuộc nói chuyện đi vào ngõ cụt. Chỉ cần tôi giữ được giọng bình thản, không khí thường sẽ tự hạ nhiệt. Cũng nên nói thẳng ra cảm xúc đang có, kiểu như \u0026ldquo;Mình hiểu chuyện này nghe có thể hơi khó chịu\u0026rdquo;, thay vì giả vờ như không hề có chút căng thẳng nào.\nNói cụ thể, và nhận đó là quan sát của riêng mình # Góp ý chung chung dễ bị nghe thành công kích cá nhân; góp ý cụ thể thì nhắm vào một hành vi. \u0026ldquo;Lúc nào bạn cũng mắc lỗi này\u0026rdquo; khác rất xa với \u0026ldquo;Mình để ý chuyện này lặp lại vài lần rồi, và nghĩ là nên nói ra.\u0026rdquo; Câu thứ hai nói từ góc nhìn của chính mình (người ta hay gọi là \u0026ldquo;I\u0026rdquo; statement): đó là điều tôi quan sát được, tôi nói từ phía tôi, và vẫn chừa chỗ cho khả năng tôi nhìn sai.\nBiến nó thành cuộc trò chuyện, đừng tuyên án # Khi ý chính đã đến được với người kia, đặt câu hỏi sẽ hiệu quả hơn là tiếp tục khẳng định. Một câu như \u0026ldquo;Bạn thấy nhận xét đó thế nào?\u0026rdquo; cho người kia khoảng trống để suy nghĩ, thay vì chỉ biết phản ứng. Tôi cũng cố nói rõ là mình góp ý để giúp chứ không phải để chê. Nghe thì hiển nhiên, nhưng người khác đâu đọc được suy nghĩ của bạn, họ chỉ nghe được lời bạn nói. Góp ý cũng dễ tiếp nhận hơn nhiều khi đi kèm một hướng giải quyết, thay vì chỉ là một danh sách vấn đề trơ trọi. Và nếu thấy phù hợp, tôi sẽ hỏi lại xem người kia cảm thấy thế nào về chính lời góp ý vừa rồi. Một câu \u0026ldquo;Mình nói vậy có công bằng không?\u0026rdquo; giúp cuộc nói chuyện cân bằng trở lại, và cũng là cách tôi làm gương cho sự cởi mở mà tôi đang mong ở người kia.\nBiết lúc nào nên dừng # Có những lúc chẳng cách nào ở trên có tác dụng, bức tường vẫn sừng sững ở đó. Lúc ấy càng cố ép thì càng vô ích. Bạn hoàn toàn có thể tạm gác cuộc nói chuyện lại, đợi một hai ngày cho mọi thứ lắng xuống rồi hẵng quay lại. Lời góp ý thì để đến mai vẫn còn đúng.\nĐiều cuối cùng này là điều tôi mất nhiều thời gian nhất mới học được.\n","date":"18/03/2023","description":"Những điều tôi học được về cách góp ý khi người đối diện đã bắt đầu dựng tường phòng thủ.","externalUrl":null,"permalink":"/vi/posts/2023/2023-03-18-empathic-feedback/","section":"Bài viết","summary":"","title":"Góp ý thế nào khi người kia bắt đầu phòng thủ","type":"posts"},{"content":"Đây là những phím tắt tôi dùng nhiều nhất trên Mac, Vim, Terminal và Slack. Chẳng có gì cao siêu — chỉ là mấy cái đã thật sự quen tay.\nMac # ctrl + phím mũi tên — chuyển qua lại giữa các cửa sổ đang mở ctrl + cmd + f — bật/tắt chế độ toàn màn hình cmd + w — đóng cửa sổ hiện tại Vim # vimtutor — bài hướng dẫn có sẵn của Vim, chạy thẳng từ shell v — chọn theo khối (visual block) y — yank (sao chép) vùng đang chọn Terminal # sudo !! — chạy lại lệnh vừa gõ với quyền sudo cmd + n — mở tab terminal mới ctrl + shift + n — đóng terminal shift + cmd + mũi tên — nhảy sang terminal kế bên cmd + d — chia đôi cửa sổ terminal cmd + shift + d — đóng khung vừa chia Slack # cmd + k — mở ô tìm kiếm F6 — nhảy sang mục kế tiếp cmd + / — xem toàn bộ phím tắt của Slack Tôi sẽ bổ sung thêm ở một bài sau.\n","date":"19/12/2021","description":"Những lệnh hữu ích dành cho lập trình viên","externalUrl":null,"permalink":"/vi/posts/2021/2021-12-19-commands/","section":"Bài viết","summary":"","title":"Những phím tắt hữu ích cho lập trình viên full stack, phần 1","type":"posts"},{"content":" Mục đích # Với SSH, bạn có thể cho chạy lệnh mỗi khi có người thử đăng nhập. Bài này hướng dẫn cách kết nối với Slack và điều khiển thứ tự thực thi của ssh Webhook trên Slack API # Xem hướng dẫn tạo app tại đây.\nBạn cần tạo một Slack app, bật webhook rồi tạo một incoming webhook để Slack nhận được thông báo từ SSH.\nViết script cho lần đăng nhập thất bại # Bạn có thể lưu tệp dưới đây ở bất cứ đâu trên hệ thống, tôi thì để ở /var/opt/notify-attempt.sh #!/bin/bash if [ \u0026#34;$PAM_TYPE\u0026#34; != \u0026#34;close_session\u0026#34; ]; then url=\u0026#34;\u0026lt;YOUR SLACK WEBHOOK\u0026gt;\u0026#34; channel=\u0026#34;#channel\u0026#34; host=\u0026#34;$(hostname)\u0026#34; content=\u0026#34;\\\u0026#34;attachments\\\u0026#34;: [ { \\\u0026#34;mrkdwn_in\\\u0026#34;: [\\\u0026#34;text\\\u0026#34;, \\\u0026#34;fallback\\\u0026#34;], \\\u0026#34;fallback\\\u0026#34;: \\\u0026#34;SSH login: $PAM_USER connected to \\`$host\\`\\\u0026#34;, \\\u0026#34;text\\\u0026#34;: \\\u0026#34;SSH login to \\`$host\\`\\\u0026#34;, \\\u0026#34;fields\\\u0026#34;: [ { \\\u0026#34;title\\\u0026#34;: \\\u0026#34;User\\\u0026#34;, \\\u0026#34;value\\\u0026#34;: \\\u0026#34;$PAM_USER\\\u0026#34;, \\\u0026#34;short\\\u0026#34;: true }, { \\\u0026#34;title\\\u0026#34;: \\\u0026#34;IP Address\\\u0026#34;, \\\u0026#34;value\\\u0026#34;: \\\u0026#34;$PAM_RHOST\\\u0026#34;, \\\u0026#34;short\\\u0026#34;: true } ], \\\u0026#34;color\\\u0026#34;: \\\u0026#34;#F35A00\\\u0026#34; } ]\u0026#34; curl -X POST --data-urlencode \u0026#34;payload={\\\u0026#34;channel\\\u0026#34;: \\\u0026#34;$channel\\\u0026#34;, \\\u0026#34;mrkdwn\\\u0026#34;: true, \\\u0026#34;username\\\u0026#34;: \\\u0026#34;SSH Notifications\\\u0026#34;, $content, \\\u0026#34;icon_emoji\\\u0026#34;: \\\u0026#34;:inbox-tray:\\\u0026#34;}\u0026#34; \u0026#34;$url\u0026#34; \u0026amp; fi exit Với các sự kiện SSH khác # Với các sự kiện khác, chẳng hạn đăng nhập thành công, tôi khuyên bạn nên tạo hai tệp riêng. Mỗi tệp chứa nội dung JSON tương ứng để gửi lên Slack. Bạn sẽ cần sửa dòng fallback\\\u0026quot;: \\\u0026quot;SSH login: $PAM_USER connected to \\$host`\u0026quot;` Ngay bên dưới là chỗ Openssh gọi các script này Cấu hình ssh # Tôi đã comment dòng gửi thông báo khi đăng nhập thành công; muốn dùng thì bạn bỏ comment đi để script chạy và báo lên Slack. Dòng này:# auth optional pam_exec.so /var/opt/notify-login.sh\n[success=2] nghĩa là nếu đăng nhập ssh thành công thì sẽ nhảy qua hai dòng, tức là bỏ qua phần thông báo đăng nhập thất bại.\nTệp: /etc/pam.d/common-auth\n# /etc/pam.d/common-auth - authentication settings common to all services # # This file is included from other service-specific PAM config files, # and should contain a list of the authentication modules that define # the central authentication scheme for use on the system # (e.g., /etc/shadow, LDAP, Kerberos, etc.). The default is to use the # traditional Unix authentication mechanisms. # # As of pam 1.0.1-6, this file is managed by pam-auth-update by default. # To take advantage of this, it is recommended that you configure any # local modules either before or after the default block, and use # pam-auth-update to manage selection of other modules. See # pam-auth-update(8) for details. # here are the per-package modules (the \u0026#34;Primary\u0026#34; block) auth [success=2 default=ignore] pam_unix.so nullok auth optional pam_exec.so /var/opt/notify-attempt.sh # here\u0026#39;s the fallback if no module succeeds auth requisite pam_deny.so # prime the stack with a positive return value if there isn\u0026#39;t one already; # this avoids us returning an error just because nothing sets a success code # since the modules above will each just jump around # For successful login, uncomment the below line, ensure the file exists. # auth optional pam_exec.so /var/opt/notify-login.sh # auth required pam_permit.so # and here are more per-package modules (the \u0026#34;Additional\u0026#34; block) auth optional pam_cap.so # end of pam-auth-update config Cảm ơn bạn! # Nếu có câu hỏi hay góp ý gì, bạn cứ liên hệ trực tiếp với tôi nhé ","date":"14/12/2021","description":"Dùng PAM và incoming webhook của Slack để nhận thông báo khi có người thử SSH vào máy chủ, kèm tuỳ chọn báo cả những lần đăng nhập thành công.","externalUrl":null,"permalink":"/vi/posts/2021/2021-12-14-ssh-log/","section":"Bài viết","summary":"","title":"Nhận thông báo Slack mỗi khi có người SSH vào máy chủ của bạn","type":"posts"},{"content":" Mục đích # Một trong những quán cà phê tôi thích nhất ở Hàn Quốc đang muốn đẩy mạnh kinh doanh bằng cách quảng bá nhiều hơn trên mạng. Muốn nâng tầm thương hiệu, một cách hiệu quả là chụp ảnh thực đơn thật chuyên nghiệp, giống như các thương hiệu lớn vẫn thuê food stylist dàn dựng ảnh món ăn cho thực đơn của họ.\nBài này tập trung vào quy trình chụp ảnh cà phê, gồm các bước: bố trí ánh sáng và chọn góc đặt máy trong quán, chỉnh thông số máy ảnh và đèn flash cho phù hợp, canh đúng thời điểm bấm máy, và cuối cùng là hậu kỳ để xóa những khuyết điểm do đèn quá sáng gây ra.\nThiết bị # Đèn speedlight Godox\nSony A6000 gắn ống SEL70200F4, tiêu cự quy đổi sang 35 mm là 105-300mm\nVị trí đèn # Master (đèn số một) # Gắn trên hot shoe của máy ảnh.\nSlave (đèn số hai) # Gắn trên monopod, giơ phía trên chủ thể như trong hình.\nVì sao dùng speedlight # Tốc độ màn trập càng nhanh thì ảnh càng nét, nhất là khi không có chân máy để giữ máy ảnh. Không dùng speedlight thì tốc độ màn trập chỉ lên được tối đa khoảng 1/200 Thông số máy ảnh # 200mm # Tiêu cự dài giúp ảnh \u0026ldquo;phẳng\u0026rdquo; lại, chủ thể đỡ bị méo, kéo giãn hơn hẳn.\n800 ISO # ISO thấp giữ được chất lượng ảnh mà ảnh vẫn đủ sáng.\n1/640sec # Tốc độ màn trập nhanh thì những vật được flash chiếu sáng sẽ hiện lên rõ ràng, còn mọi thứ phía sau tối hẳn đi.\nF/6.3 # Chỉ số khẩu độ (f-stop) cao hơn cho vùng lấy nét sâu hơn, nhờ vậy từ điểm gần nhất của chiếc cốc cho đến phần mép xa máy nhất đều nét.\nHậu kỳ # Photoshop # Ảnh hoàn thiện # ","date":"24/08/2019","description":"Hậu trường buổi chụp ảnh cho thực đơn của Paz Coffee!","externalUrl":null,"permalink":"/vi/posts/2019/2019-08-24-foodphotography/","section":"Bài viết","summary":"","title":"Chụp ảnh đồ ăn","type":"post"},{"content":"Tuần sau là Gwangbokjeol (광복절), ngày Quang phục 15/8 của Hàn Quốc, kỷ niệm ngày đất nước thoát khỏi ách thống trị của Nhật. Theo truyền thống, vào dịp này người ta treo quốc kỳ trước nhà hoặc ngoài cửa sổ căn hộ. Nhân ngày lễ, và cũng để luyện thêm tay nghề JavaScript, tôi thử dùng một library giả 3D khá hay để làm một lá cờ ảo.\nTài liệu chính thức # https://zzz.dog/\nToàn bộ mã nguồn của ví dụ này có trên CodePen, bạn cứ vào xem.\nVí dụ của tôi # Xem Pen 광복절 của Jared Lynskey (@jared-lynskey) trên CodePen. ","date":"08/08/2019","description":"Dùng ZDog, một library JavaScript giả 3D, để treo lá cờ ảo mừng Gwangbokjeol.","externalUrl":null,"permalink":"/vi/posts/2019/2019-08-08-gwangbokjeol/","section":"Bài viết","summary":"","title":"Thư viện giả 3D ZDog","type":"post"},{"content":"CSS của trang này cứ phình dần thành một tệp dài dằng dặc, lặp đi lặp lại, nên cuối cùng tôi cũng chịu chuyển nó sang SASS. Nếu bạn đang chạy một trang GoHugo thì đây là kiểu thay đổi chỉ tốn mười lăm phút, nhưng về sau lần nào đụng vào stylesheet bạn cũng thấy xứng đáng.\nDưới đây là cách tôi thiết lập.\nSao lại dùng SASS? # CSS thuần sẽ bắt đầu rối khi trang web lớn hơn dăm ba trang. SASS cho bạn biến, rule lồng nhau, mixin và function, nên những thứ như màu sắc hay các đoạn layout dùng chung chỉ cần đặt ở một chỗ, thay vì copy-paste khắp nơi. Hay nhất với người dùng Hugo là Hugo Pipes tự biên dịch SCSS giúp bạn — không cần build tool riêng, cũng không cần script watcher.\nHai trang nên mở sẵn trong lúc làm:\nTài liệu SASS Tài liệu hỗ trợ SCSS/SASS của Hugo Thiết lập # 1. Sắp xếp lại thư mục assets # Hugo tìm thư mục assets ở thư mục gốc của project, nên bạn hãy tạo thư mục này (hoặc chuyển thư mục đang có) ra đó. Nếu muốn để nó nằm bên trong resources, hãy khai báo cho Hugo trong config.toml:\nassetDir = \u0026#34;resources/assets\u0026#34; 2. Tạo thư mục sass # Bên trong assets, tạo một thư mục tên là sass. Các tệp SCSS của bạn sẽ nằm ở đây.\n3. Thêm entry point # Tạo tệp main.scss bên trong thư mục sass. Đây là tệp mà Hugo sẽ biên dịch, nên cứ thêm vào một ít SCSS để kiểm tra xem nó đã chạy chưa:\n// assets/sass/main.scss // Variables $primary-color: #3498db; // Mixin @mixin center { display: flex; justify-content: center; align-items: center; } // Styles body { font-family: Arial, sans-serif; background-color: $primary-color; } h1 { @include center; color: white; } 4. Gắn vào template # Trong partial head.html, load stylesheet đã biên dịch rồi minify nó:\n{{ $style := resources.Get \u0026#34;sass/main.scss\u0026#34; | resources.ToCSS | resources.Minify }} \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;{{ $style.Permalink }}\u0026#34;\u0026gt; Chỉ cần nhớ là tên tệp ở đây phải khớp với entry point thật của bạn (main.scss).\nChia nhỏ style # Khi mọi thứ lớn dần, hãy tách SCSS thành các partial rồi import chúng từ main.scss:\n// assets/sass/_variables.scss $primary-color: #3498db; // assets/sass/_mixins.scss @mixin center { display: flex; justify-content: center; align-items: center; } // assets/sass/main.scss @import \u0026#34;variables\u0026#34;; @import \u0026#34;mixins\u0026#34;; body { background-color: $primary-color; } h1 { @include center; color: white; } Nói thật là chỉ có vậy thôi. Bốn bước, và Hugo sẽ biên dịch lại style mỗi lần build. Tôi cứ tưởng sẽ phải vật lộn với asset pipeline, ai ngờ mọi thứ chạy ngon luôn.\n","date":"06/08/2019","description":"Cách tôi thiết lập để biên dịch SCSS cho trang Hugo này bằng Hugo Pipes, chỉ trong bốn bước ngắn.","externalUrl":null,"permalink":"/vi/posts/2019/2019-08-06-understandingsass/","section":"Bài viết","summary":"","title":"Bắt đầu dùng Sassy CSS trong GoHugo","type":"post"},{"content":" Bối cảnh # Từ khi có drone giá rẻ, số người dùng drone tăng lên thấy rõ, chủ yếu vì drone rất cơ động và làm được nhiều việc trên không. Giờ 5G đã cận kề, giới nghiên cứu đang dồn sức tìm cách mới để tối ưu vị trí đặt drone, cả khi bay trên không lẫn khi đậu dưới mặt đất. Mục tiêu là cải thiện kết nối di động, đồng thời giảm latency trung bình giữa thiết bị người dùng trên không và thiết bị dưới mặt đất.\nA Tutorial on UAVs for Wireless Networks: Applications, Challenges, and Open Problems # Các loại UAV # Trong bài báo [1], các tác giả phân loại UAV bằng một sơ đồ cây, chia nhánh dần ra thành các nhóm nhỏ hơn.\nBeyond 5G with UAVs: Foundations of a 3D Wireless Cellular Network # Giới thiệu # Khi drone được dùng với số lượng lớn, mạng không dây sẽ chịu ảnh hưởng đáng kể. Drone có thể dùng để chụp ảnh nhiệt, giao hàng, hay làm trạm gốc trên không. Bài báo này xét các thiết bị người dùng bay ở tầm thấp trong không gian 3D. Phục vụ chúng là các access point đặt trên những nền tảng tầm cao, tức các UAV như khí cầu. Bối cảnh # Các nghiên cứu trước đây chưa tính tới trường hợp thiết bị người dùng (UE) chính là drone đang bay. Các nghiên cứu trước về user association (gán người dùng vào trạm) trong mạng drone cũng chưa tính đến trạm gốc trên không. Mô hình hệ thống # Có hai loại drone: drone bay tầm cao làm access point, phục vụ các drone UE bay tầm thấp. ![Mô hình hệ thống](/img/posts/drones/systemmodel.jpg) Lý thuyết vận tải # ![Quy trình của hệ thống](/img/posts/drones/model.jpg) - Các tác giả áp dụng lý thuyết vận tải tối ưu (optimal transport), vì họ nhận thấy cách này giảm được latency của drone-UE so với cách gán truyền thống dựa trên SINR. Đóng góp của tôi # Facility Location Problem Approach for Distributed Drones # Hình trên minh họa ý tưởng của tôi: đặt các trạm drone (drone port) ở những khu vực có nhu cầu cao. Trạm to, có màu là những trạm tối ưu được chọn. Còn trạm nhỏ, tô xám là những vị trí tiềm năng nhưng không tối ưu nên bị loại.\nBài toán vị trí cơ sở (Facility Location Problem) # Dựa vào bài toán vị trí cơ sở, ta có thể lập một bài toán tối ưu để tìm ra chỗ đặt trạm drone tốt nhất, trong giới hạn ngân sách và trong số các vị trí tiềm năng đã có.\nTài liệu tham khảo # [1] Mozaffari, Mohammad, et al. \u0026ldquo;A tutorial on UAVs for wireless networks: Applications, challenges, and open problems.\u0026rdquo; arXiv preprint arXiv:1803.00680 (2018).\n[2] Mozaffari, Mohammad, et al. \u0026ldquo;Beyond 5G with UAVs: Foundations of a 3D wireless cellular network.\u0026rdquo; IEEE Transactions on Wireless Communications 18.1 (2019): 357-372.\n[3] Lynskey, Jared, et al. \u0026ldquo;Facility Location Problem Approach for Distributed Drones.\u0026rdquo; Symmetry 11.1 (2019): 118.\n","date":"01/04/2019","description":"Mạng phân tầng bằng thiết bị bay không người lái!","externalUrl":null,"permalink":"/vi/posts/2019/2019-01-26-dronesandstuff/","section":"Bài viết","summary":"","title":"Drone trong mạng thế hệ mới","type":"post"},{"content":" Dữ liệu của bạn \u0026ldquo;già\u0026rdquo; cỡ nào? # Hồi làm ở Methanex NZ, tôi đúc kết được sáu tiêu chí đánh giá dữ liệu, và độ kịp thời (Data Timeliness) là một trong số đó: thông tin càng mới thì càng có giá trị! Gần đây, giới nghiên cứu liên tục đề xuất các thuật toán lên lịch thu thập dữ liệu từ cảm biến và thiết bị IoT, sao cho Age of Information (AoI, tạm hiểu là \u0026ldquo;tuổi\u0026rdquo; của thông tin) trong mạng không dây thế hệ mới (5G) càng thấp càng tốt.\nChất lượng dữ liệu # Trong thời gian ở Methanex NZ, tôi có làm một nghiên cứu về chất lượng dữ liệu (Data Quality). Dưới đây là những tiêu chí chính tôi rút ra để đánh giá dữ liệu tốt hay không.\nĐộ kịp thời (Data Timeliness): dữ liệu còn giá trị đến đâu tại thời điểm được dùng. Độ đầy đủ (Completeness): dữ liệu trong hệ thống có bị thiếu trường nào không. Tính nhất quán (Consistency): dữ liệu có được giữ thống nhất với nhau hay không. Độ tin cậy (Confidence): nguồn dữ liệu đáng tin đến mức nào. Độ chính xác (Accuracy): dữ liệu phản ánh thực tế sát đến đâu. Tính toàn vẹn (Integrity): dữ liệu đến từ bao nhiêu nguồn, và các nguồn đó khớp nhau đến mức nào. Chào mừng bạn đến với thời đại của dữ liệu. # Age of Information # Age of Information đo độ \u0026ldquo;tươi\u0026rdquo; của dữ liệu [1]. Gần đây chủ đề này được chú ý nhiều là do quá trình chuyển từ 4G lên 5G. Khác với các thế hệ trước, 5G đặt nặng vai trò phục vụ các thiết bị Internet of Things (IoT) và giao tiếp giữa máy với máy (Machine Type Communication, MTC) [2].\nNghiên cứu mới nhất về AoI # Reinforcement Learning Based Scheduling Algorithm for Optimizing Age of Information in Ultra Reliable Low Latency Networks [1]\nBài báo này lấy bối cảnh một nhà máy có nhiều cảm biến liên tục gửi dữ liệu về trạng thái hiện tại của chúng. Các cảm biến không có bộ nhớ riêng để lưu dữ liệu. Vì vậy, muốn ghi lại trạng thái hiện tại của một cảm biến thì bộ lập lịch (scheduler) từ xa phải chọn đúng tín hiệu của cảm biến đó, còn tín hiệu của các cảm biến khác sẽ bị access point của scheduler bỏ qua. Từ đây, scheduler gặp một bài toán khó: chọn thế nào cho tối ưu giữa những cảm biến không đồng nhất, mà vẫn đáp ứng được các yêu cầu khắt khe về thời gian. Theo các tác giả, \u0026ldquo;thành phần URLLC được tối thiểu hóa bằng cách bảo đảm xác suất AoI của mỗi cảm biến vượt quá một ngưỡng định trước là nhỏ nhất.\u0026rdquo; Để giải bài toán này, họ đề xuất một hướng tiếp cận dựa trên machine learning.\nMinimizing the Age of Information from Sensors with Correlated Observations [3]\nBài báo này nghiên cứu \u0026ldquo;AoI trung bình trong một bối cảnh tổng quát, khi thông tin từ các cảm biến có tương quan với nhau\u0026rdquo;. Khi nhiều cảm biến cùng quan sát một nguồn, dữ liệu gửi về access point sẽ bị trùng lặp, và ta hoàn toàn có thể cắt bớt phần trùng đó. Muốn vậy cần một policy thông minh để lên lịch cho access point nhận dữ liệu từ các cảm biến có tốc độ truyền và kích thước dữ liệu khác nhau. Policy dựa trên machine learning giúp scheduler nhận ra dữ liệu của từng cảm biến đến từ nguồn nào, rồi chỉ nhận lượng dữ liệu ít nhất cần thiết, nhờ đó giảm tối đa age of information của cả hệ thống.\n![Độ kịp thời của dữ liệu](/img/posts/aoi/fig1.jpg) Tài liệu tham khảo # [1] Elgabli, A., Khan, H., Krouka, M., \u0026amp; Bennis, M. (2018). Reinforcement learning based scheduling algorithm for optimizing age of information in ultra reliable low latency networks. arXiv preprint arXiv:1811.06776. [2] Boccardi, Federico, et al. \u0026ldquo;Five disruptive technology directions for 5G.\u0026rdquo; IEEE Communications Magazine 52.2 (2014): 74-80.\n[3] Kalør, Anders E., and Petar Popovski. \u0026ldquo;Minimizing the Age of Information from Sensors with Correlated Observations.\u0026rdquo; arXiv preprint arXiv:1811.06453 (2018).\n","date":"23/01/2019","description":"Độ kịp thời của dữ liệu, chỉ số Age of Information, và cách giới nghiên cứu dùng reinforcement learning để lên lịch thu thập dữ liệu từ cảm biến trong mạng 5G.","externalUrl":null,"permalink":"/vi/posts/2019/2019-01-23-ageofinformation/","section":"Bài viết","summary":"","title":"Age of Information trong mạng không dây","type":"post"},{"content":"Học kỳ nào phòng lab của chúng tôi cũng đi dự KSC và KCC, hai hội nghị tin học lớn trong nước. Mùa đông năm nay đến lượt KSC, tức Korea Software Congress (한국소프트웨어종합학술대회, Hội nghị Tổng hợp Phần mềm Hàn Quốc), diễn ra từ ngày 19 đến 21/12/2018 tại Phoenix Park ở PyeongChang, tỉnh Gangwon. Đúng rồi, chính là PyeongChang vừa đăng cai Thế vận hội Mùa đông hồi tháng Hai.\nĐôi nét về KSC 2018 # KSC do Hội Khoa học Thông tin Hàn Quốc (한국정보과학회) tổ chức, và là một trong những dịp tụ họp lớn nhất năm của giới nghiên cứu khoa học máy tính ở Hàn Quốc. Lý do chính thức để đi là nghe trình bày bài báo và keynote. Còn lý do không chính thức là gặp gỡ, hàn huyên với anh em ở các lab khác.\nNăm nay hội nghị kéo dài ba ngày hai đêm ở Phoenix Park. Chủ tịch hội nghị là Han Jeong-hyeon (Đại học Korea), chủ tịch chương trình là Kim Hyeong-seok (Đại học Konkuk), còn trưởng ban tổ chức là Han Moon-seok (Đại học Quốc gia Gangneung-Wonju). Nhà tài trợ gồm có Samsung Electronics, Gangwon Convention Bureau, LINE Plus, Kakao, College of Software Convergence của Đại học Kookmin, NAVER, Manycore Soft và Signtel.\nHội nghị có gì # Chương trình khá dày: các phiên trình bày bài báo thuộc đủ mảng quen thuộc của khoa học máy tính, keynote và bài giảng mời, tutorial, rồi workshop và các phiên đặc biệt, trong đó có phiên giới thiệu những dự án nghiên cứu cấp quốc gia quy mô lớn. Bên cạnh đó còn có cuộc thi blockchain LINE X KIISE, cuộc thi triển khai/demo phần mềm để sinh viên đại học và cao học khoe sản phẩm mình làm ra, cùng các gian triển lãm và gian tuyển dụng cho ai đang tìm việc. 10% bài báo và bài trình bày xuất sắc nhất sẽ được đề cử đăng tạp chí, một phần thưởng khá hấp dẫn đấy.\nVới dân cao học, cái lợi thật sự là được xem tất cả các lab khác trong cả nước đang nghiên cứu gì, gói gọn ở cùng một chỗ. Mà được làm chuyện đó giữa PyeongChang tuyết phủ trắng xóa thì cũng chẳng tệ chút nào.\nBạn có thể xem trang web chính thức của KSC 2018 ở đây.\n","date":"20/12/2018","description":"Chuyến đi cuối năm của phòng lab chúng tôi tới KSC 2018, tổ chức ở Phoenix Park, PyeongChang.","externalUrl":null,"permalink":"/vi/posts/2018/2018-12-20-pyeongchang/","section":"Bài viết","summary":"","title":"Korea Software Congress 2018 tại PyeongChang","type":"posts"},{"content":"Năm nay Kiwi Chamber tổ chức khá nhiều buổi gặp mặt ăn sáng, và hôm nay là buổi cuối cùng. Hai diễn giả khách mời lần này là Dr Pau Medrano-Gracia đến từ Đại học Auckland và giáo sư Kim Gun-ha đến từ Đại học Ewha.\nRobot chăm sóc người sa sút trí tuệ # Giáo sư Kim là chuyên gia thần kinh học. Bài giảng của bà xoay quanh \u0026ldquo;Robot chăm sóc người sa sút trí tuệ\u0026rdquo;, trong đó có tính năng hỗ trợ sinh hoạt hằng ngày, giúp bệnh nhân sa sút trí tuệ được chăm sóc ngay tại nhà. Bà giới thiệu những con robot liên tục tự học để nhận diện ngày càng tốt hơn, cùng các chương trình rèn luyện nhận thức dùng chính những con robot đó. Hiện bà đang cùng các cộng sự phát triển robot chăm sóc bệnh nhân sa sút trí tuệ tại gia, với sự hỗ trợ của Bộ Thương mại, Công nghiệp và Năng lượng Hàn Quốc. Năm 2019, robot của dự án sẽ qua một vòng đánh giá hiệu quả. Nếu đạt, chúng sẽ được đưa vào sử dụng thực tế, mở đường cho dòng robot chăm sóc người sa sút trí tuệ dành cho các hộ gia đình.\nUniservices # Tiến sĩ Medrano-Gracia kể về công việc của ông ở Uniservices, với vai trò Business Development Manager (quản lý phát triển kinh doanh). Uniservices hợp tác với những bộ óc giỏi nhất của Đại học Auckland, đem tư duy sắc sảo của họ vào những ý tưởng có tiềm năng thay đổi thế giới.\nUpstarters # Ý tưởng mới từ một vùng đất mới # Người ta vẫn nói cái khó ló cái khôn. Vậy thì cũng chẳng lạ khi nhiều ý tưởng làm thay đổi thế giới lại ra đời ở một nơi xa xôi như New Zealand. Phần này giới thiệu câu chuyện công nghệ và đổi mới sáng tạo của New Zealand.\nẢnh chụp tại sự kiện # Tôi rất thích đi làm tình nguyện ở những sự kiện góp phần vun đắp quan hệ giữa hai nước.\nMẹo nhỏ: chụp trong phòng tối thì nhớ mang theo ống kính sáng (khẩu độ lớn)! Cứ thử nghĩ xem khán giả và diễn giả sẽ khó chịu cỡ nào nếu bạn liên tục nháy cái đèn flash to đùng để bù sáng. Nhìn chẳng hay ho chút nào!\n","date":"12/11/2018","description":"Tôi đi chụp ảnh cho buổi gặp mặt ăn sáng về chủ đề lãnh đạo của Kiwi Chamber, nơi các diễn giả nói về robot chăm sóc người sa sút trí tuệ và câu chuyện đổi mới sáng tạo của New Zealand.","externalUrl":null,"permalink":"/vi/posts/2018/2018-11-12-kiwichamberbreakfast/","section":"Bài viết","summary":"","title":"Kiwi Chamber NZ: chuỗi diễn đàn về lãnh đạo","type":"posts"},{"content":"Ở New Zealand, học sinh đa phần đều đi làm thêm sau giờ học để có tiền đi chơi với bạn bè. Hồi đó tôi làm thêm ở hai chỗ: bộ phận phụ tùng (Parts Department) và một tiệm làm biển hiệu. Dưới đây là một việc tôi từng làm ở tiệm biển hiệu.\nThử tưởng tượng thế này: bạn phải làm lại một tấm decal to đến mức không cho vừa máy quét thông thường, vậy mà bản làm lại vẫn phải đủ độ phân giải để in khổ lớn.\nLần đó khách cần làm lại tấm decal càng sớm càng tốt, vì nếu đặt hàng thay thế từ Hà Lan thì phải chờ tới bốn tuần mới về.\nTấm decal gốc # Như bạn thấy đấy, tấm decal đã cũ và sờn đi khá nhiều.\nCách làm # Mẹo là tận dụng luôn cái máy ảnh có sẵn ở tiệm: chụp cận từng phần của tấm decal, rồi ghép các ảnh lại với nhau để ra một bức ảnh nét nhất có thể.\nTôi ghép ảnh bằng tính năng Photomerge của Photoshop. Nhờ vậy, phần chuyển màu từ xanh sang trắng giữ được đủ chi tiết để vẽ lại cho chính xác.\nKết quả # Khách rất hài lòng vì có ngay decal mới, không phải ngồi chờ hàng gửi từ nước ngoài về.\n","date":"20/02/2014","description":"Hồi làm thêm ở một tiệm làm biển hiệu tại New Zealand, tôi đã làm lại mấy tấm decal quá khổ chỉ bằng máy ảnh và Adobe Illustrator.","externalUrl":null,"permalink":"/vi/posts/2014/2014-01-09-signwriting/","section":"Bài viết","summary":"","title":"Làm lại decal ở tiệm làm biển hiệu","type":"posts"}]