<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Edward Shen</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://edwardshen125.github.io/</id>
  <link href="https://edwardshen125.github.io/" rel="alternate"/>
  <link href="https://edwardshen125.github.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Edward Shen</rights>
  <subtitle>
    <![CDATA[Backend & Distributed Systems Engineer]]>
  </subtitle>
  <title>Edward Shen</title>
  <updated>2026-10-20T14:36:50.000Z</updated>
  <entry>
    <author>
      <name>Edward Shen</name>
    </author>
    <category term="Engineering Case Study" scheme="https://edwardshen125.github.io/categories/Engineering-Case-Study/"/>
    <category term="go" scheme="https://edwardshen125.github.io/tags/go/"/>
    <category term="flink" scheme="https://edwardshen125.github.io/tags/flink/"/>
    <category term="kafka" scheme="https://edwardshen125.github.io/tags/kafka/"/>
    <category term="ticdc" scheme="https://edwardshen125.github.io/tags/ticdc/"/>
    <category term="data-platform" scheme="https://edwardshen125.github.io/tags/data-platform/"/>
    <category term="lifecycle-marketing" scheme="https://edwardshen125.github.io/tags/lifecycle-marketing/"/>
    <category term="audience-segmentation" scheme="https://edwardshen125.github.io/tags/audience-segmentation/"/>
    <content>
      <![CDATA[<p>Lifecycle campaigns repeatedly combined latest user traits,historical events, and expiring assets. Operations initially assembledthose audiences manually; the platform already had transactionalservices and a <ahref="https://docs.pingcap.com/tidb/stable/overview">TiDB</a>/<ahref="https://docs.pingcap.com/tidb/stable/ticdc-overview">TiCDC</a>/<ahref="https://kafka.apache.org/">Kafka</a>/<ahref="https://flink.apache.org/">Flink</a> data path, but marketingneeded reusable audience definitions and automated email tasks ratherthan campaign-specific filters.</p><p>I wrote the design document and early core demo for predicateexpansion, SQL compilation, segment refresh, and the reach handoff. Acolleague implemented the production services with my guidance.</p><span id="more"></span><img data-src="/posts/engineering-case-study/building-a-predicate-driven-audience-pipeline-for-lifecycle-email/architecture.png" class="" title="Predicate-driven audience pipeline architecture"><h2 id="context-and-requirements">Context and requirements</h2><p>The work ran on the Go microservice platform described earlier inthis series. Product and operations needed lifecycle campaigns forregistration, activity, transaction behavior, membership progression,and expiring rewards. The data platform already provided TiDB, TiCDC,Kafka, Flink, and layered analytical tables.</p><p>The design treated each predicate as a reusable backend contract.Marketing owned predicates, compilation, segments, lifecycle plans, andreach events. Reach owned message delivery, channel policy, frequencycontrol, fallback, receipts, and message persistence.</p><p>Audience conditions came from different data shapes:</p><ul><li>latest user traits, such as activity, transaction totals, andmembership level;</li><li>historical events, such as registration or level-up;</li><li>time-sensitive asset state, such as reward or entitlementexpiry;</li><li>combinations of those conditions.</li></ul><p>Without a shared predicate model, each campaign would carry its ownfilter expression, scan, and audience result. That would make audiencelogic difficult to review and would leave later plans without a stabledefinition to reuse. Different campaigns could still choose differentpredicates; the platform made the selected definition explicit andreusable.</p><p>The pipeline had to reuse that platform rather than introduce anotherwarehouse. It also had to keep changing while feature deliverycontinued.</p><p>The design had to meet six requirements:</p><ul><li>define audiences with business predicates instead ofcampaign-specific SQL;</li><li>keep UI predicates separate from physical table routing;</li><li>let latest traits, events, assets, and aggregates retain their ownstorage semantics;</li><li>reuse an identical audience definition instead of creating anothermember table;</li><li>bound segment replacement and event size during refresh;</li><li>send lifecycle email and retain a per-user delivery record.</li></ul><h2 id="options-considered">Options Considered</h2><p>The simplest option was to let each campaign own its SQL. Thatpreserved short-term flexibility, but it duplicated the meaning ofcommon predicates and left no canonical place to review audiencelogic.</p><p>A canonical audience table was the second option. Every source eventcould be transformed into a common user-attribute row. Querying wouldbecome uniform, but event history, latest traits, and expiring assetswould be forced into one update and freshness model. A row representing"user registered" has different semantics and retention needs from a rowrepresenting "reward expires at this timestamp."</p><p>We also considered three fixed storage shapes: latest traits, eventhistory, and asset status. That model was easier to explain, but itstill pushed source data into a table even when an aggregate or detailtable already represented the condition better.</p><p>The selected design kept the warehouse layered and moved physicalrouting into atomic-predicate metadata. A predicate could target a keyedtrait table, an event detail table, an aggregate table, or another DWDtable that matched its semantics.</p><h2 id="architecture">Architecture</h2><h3 id="separate-business-predicates-from-physical-routes">Separatebusiness predicates from physical routes</h3><p>Composite predicates were the only business-facing layer. Theycarried labels, categories, parameter definitions, allowed operators, UIhints, and expansion templates. Operations could configure concepts suchas consecutive active days, transaction stage, transaction frequency,membership level, level-up reached, or asset validity.</p><p>Atomic predicates carried physical routing: table, alias, key column,value column, value type, and optional fixed key. For example, a keyedtrait route could point to <code>dws_user_latest_traits</code>, compare<code>trait_key</code>, and read either <code>trait_val_str</code> or<code>trait_val_num</code>. An event route could compare<code>occurred_at</code>; an asset route could filter<code>asset_type</code>, <code>asset_sub_type</code>, and<code>expire_at</code>.</p><p>That split kept business language stable while allowing table routingto change. It also made predicate configuration <strong>runtime-criticalconfiguration</strong>: changing a route, value type, or table meaningchanged who matched a segment. Before adding more predicates, I woulddocument each atomic route with a named producer, table owner, refreshsemantics, primary-key meaning, and freshness target to expose stale orambiguous routes during review.</p><h3 id="compile-the-audience-ast">Compile the audience AST</h3><p>The caller submitted an AST. The backend expanded compositepredicates, rejected the request if a composite remained, and compiledan atomic-only AST.</p><p>For an <code>AND</code> root, each child became a SQL branch thatreturned <code>(uid, logic_branch_id)</code>. The branches were combinedwith <code>UNION ALL</code>, grouped by <code>uid</code>, and filteredwith:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">HAVING</span> <span class="built_in">COUNT</span>(<span class="keyword">DISTINCT</span> logic_branch_id) <span class="operator">=</span> ?</span><br></pre></td></tr></table></figure><p>The final statement wrapped the result in a CTE and added a TiFlashread hint for the routed tables. Each branch returned distinct user IDs,so the count represented the number of AND conditions satisfied. Thisavoided forcing predicates with different table semantics into oneflattened <code>WHERE</code> clause.</p><p>The compiler supported relative operators such as<code>BEFORE_INTERVAL</code>, <code>WITHIN_INTERVAL</code>, and<code>AFTER_INTERVAL</code>. A current-time option let one refresh usethe same execution timestamp for every condition instead of allowingeach SQL expression to read a slightly different <code>NOW()</code>.</p><h3 id="a-combined-audience-example">A combined audience example</h3><p>Consider an illustrative reward-reminder audience: users whoregistered at least seven days ago, have reached a configured membershiplevel, and hold a reward expiring within the next two days. Operationswould combine these business predicates under one <code>AND</code> rootand supply the age, level, and expiry parameters.</p><table><colgroup><col style="width: 33%" /><col style="width: 33%" /><col style="width: 33%" /></colgroup><thead><tr><th>Business condition</th><th>Atomic routing and filtering</th><th>Data shape</th></tr></thead><tbody><tr><td>Registered at least seven days ago</td><td>Registration event route; compare <code>occurred_at</code> with therefresh time minus seven days</td><td>Historical event</td></tr><tr><td>Membership level meets the configured threshold</td><td>Latest-trait route; select the membership trait and compare itsnumeric value</td><td>Latest state</td></tr><tr><td>Reward expires within the next two days</td><td>Asset route; select the reward type and filter<code>expire_at</code> between the refresh time and two days later</td><td>Asset state</td></tr></tbody></table><p>Composite expansion resolves the business predicates into atomicconditions. Both time-based conditions use the same refresh timestamp,so the registration-age threshold and reward-expiry window do not driftwithin one refresh.</p><p>Operations can adjust the reminder window or membership thresholdusing the existing predicate definitions. The physical routes remainbackend-owned, and another lifecycle plan can reference the same segmentwhen its expanded definition matches. The combination spans three datashapes while keeping their producers and storage semantics separate.</p><h3 id="refresh-segments-and-cross-the-service-boundary">Refreshsegments and cross the service boundary</h3><p>The production segment service stored its original AST, SHA-256 ASThash, reference count, status, and last run time. The hash normalizedsorted logical children and parameter maps, so reuse depended onexpanded semantics rather than display name. A unique index on<code>ast_hash</code> handled concurrent creation; a duplicate-key errorcaused the service to reread the canonical segment.</p><p>CronHub scheduled each segment hourly. Refresh queried old members,executed the compiled query with a fixed current time, classified addedand removed users, emitted membership-change events in batches of atmost 5,000 UIDs, and replaced membership in batches of 10,000 rows. Alifecycle task then selected tasks for that segment and published abatched <code>domain_event_reach</code> event.</p><pre class="mermaid" style="text-align: center;">                        flowchart LR  subgraph SG_M["Marketing service"]    A(["Business AST"]):::state --> B(["Expand composites<br/>validate atomic-only AST"]):::event    B --> C(["Route atomic predicates<br/>compile SQL branches"]):::event    E(["Compare old and new members<br/>emit added / removed UIDs"]):::event --> L(["Lifecycle trigger<br/>select day-based tasks"]):::event  end  D(["TiDB / TiFlash<br/>membership query"]):::state  F(["Batched domain_event_reach"]):::event  C --> D  D --> E  L --> F  classDef state fill:#eef7ff,stroke:#245a7d,stroke-width:2px  classDef event fill:#fff7e6,stroke:#8a6d3b,stroke-width:2px          </pre><p>Reach consumed the batch, created a per-user record, rendered thetemplate, called the configured provider, and recorded success orfailure. A rendering or provider failure marked that user's recordfailed and gave the batch failure isolation; <em>only if</em> everyexecution failed did the consumer return an error for retry.</p><pre class="mermaid" style="text-align: center;">                        sequenceDiagram  participant L as Lifecycle trigger  participant K as Kafka: domain_event_reach  participant R as Reach consumer  participant T as Template renderer  participant C as Email provider  participant D as reach_record  L->>K: Publish batched users  K->>R: Consume batch  R->>D: Create pending per-user records  R->>T: Render template per user  R->>C: Send email  C-->>R: Provider result  R->>D: Update delivery record          </pre><h2 id="source-processing-and-recovery-limits">Source processing andrecovery limits</h2><h3 id="source-jobs-behind-the-predicates">Source jobs behind thepredicates</h3><p>The source-routing jobs fed the atomic-predicate routes. The Trackservice accepted events over gRPC, normalized event IDs, timestamps, anduser context, and published <code>user_behavior_event</code> to Kafkathrough an async producer. TiCDC captured selected business-tablechanges into topics such as <code>user_transfer</code>,<code>reward_transaction_event</code>, <code>settlement_event</code>,and <code>order_amount_event</code>.</p><p>One Flink SQL path joined user-transfer and reward-transactionstreams with user, game, activity, and reward dimensions, normalizedamounts and sources, and wrote a daily activity/reward detail tablekeyed by <code>(ref_id, type)</code>. It ran with ten-secondcheckpoints. A separate DataStream job unified behavior, user,transaction, and settlement events, used bounded out-of-ordernesswatermarks with idle-source handling, enriched acquisition dimensions,and wrote feature tables.</p><h3 id="recovery-boundaries-in-the-demo">Recovery boundaries in thedemo</h3><p>The demo also made several production boundaries visible. Segmentrefresh emitted change events before it replaced membership, and apublish failure was logged without failing the refresh. The reach tablehad a retry count and source identifiers, but no unique idempotency keycovering a replayed batch. The DataStream job's checkpoint line wascommented out, and its DWS sink used a single JDBC connection,dynamically assembled table names, and error logging rather than a retryor dead-letter path.</p><p>Those behaviors risked duplicate email on replay, lost membershipdeltas during partial failure, and database write failures that did notstop processing. The mechanisms described here were requirements Icarried into production implementation review, not features the demoalready had.</p><p>I would redesign refresh as an explicit state machine with an outboxevent and stable event ID: persist the intended membership transitionand publication intent together, publish idempotent events, and onlythen advance the visible refresh state. In Reach, I would enforce anidempotency key such as<code>(source_id, user_id, channel_type, scheduled_at)</code> with aunique constraint and route unrecoverable failures to a dead-letterpath.</p><p>For the behavior job, I would require checkpointing, state TTL,connection pooling, feature-table schema versioning, and a boundeddynamic-SQL allowlist before accepting it for production.</p><h2 id="results">Results</h2><p>Operations could configure lifecycle audiences through sharedbusiness predicates, while the backend maintained their physical routes— latest traits, historical events, expiring assets, and aggregatescombined in one audience definition.</p><p>Five to ten lifecycle plans remained active, with additional planschanging by campaign. Operations adjusted configurations at least twicea week. This recurring use put predicate reuse and the separation ofbusiness definitions from table routing into the campaign workflow:targeting changes could use existing predicates while their SQLexpansion remained centrally defined.</p><p>The same hourly query-and-diff path handled segments ranging from afew tens of users for precise multi-condition messages to tens ofthousands for registration-day recall. That range made boundedmembership writes and batched change events relevant to routineoperation. The handoff to Reach retained a per-user email record, so afailed recipient could be inspected without stopping the remainingbatch.</p><h2 id="trade-offs">Trade-offs</h2><p>Reviewable audience configuration, segment reuse, and stable segmentIDs reduced campaign-specific work. Physical tables could evolve behindatomic predicates, but that made predicate metadata runtime-criticalconfiguration.</p><p>The Marketing/Reach boundary spread delivery diagnosis acrossservices, and behavior feature computation increased Flink state anddatabase write load. Per-user records isolated individual send failures;they did not by themselves make replay safe.</p><p>The remaining design work was at the event boundary. Segment reuseand individual failure isolation did not define how a replayed eventbecame a duplicate; replay safety required producer-side publicationstate and consumer-side idempotency to be defined together.</p><hr /><p><em>Previous in this series: <ahref="/posts/engineering-case-study/building-a-real-time-data-platform-with-tidb-cdc-and-flink/">Buildinga Real-Time Data Platform with TiDB CDC and Flink</a></em>.</p>]]>
    </content>
    <id>https://edwardshen125.github.io/posts/engineering-case-study/building-a-predicate-driven-audience-pipeline-for-lifecycle-email/</id>
    <link href="https://edwardshen125.github.io/posts/engineering-case-study/building-a-predicate-driven-audience-pipeline-for-lifecycle-email/"/>
    <published>2026-02-14T04:00:00.000Z</published>
    <summary>How reusable predicates, AST compilation, and layered TiDB data let lifecycle campaigns combine user traits, events, and expiring assets into email audiences.</summary>
    <title>Designing a Predicate-Driven Audience Pipeline for Lifecycle Email</title>
    <updated>2026-10-20T14:36:50.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Edward Shen</name>
    </author>
    <category term="Engineering Case Study" scheme="https://edwardshen125.github.io/categories/Engineering-Case-Study/"/>
    <category term="tidb" scheme="https://edwardshen125.github.io/tags/tidb/"/>
    <category term="flink" scheme="https://edwardshen125.github.io/tags/flink/"/>
    <category term="data-platform" scheme="https://edwardshen125.github.io/tags/data-platform/"/>
    <category term="cdc" scheme="https://edwardshen125.github.io/tags/cdc/"/>
    <category term="real-time" scheme="https://edwardshen125.github.io/tags/real-time/"/>
    <content>
      <![CDATA[<p>The Go backend platform had been established earlier in 2025 andeventually carried eighteen Go domain services. Product teams could shipfeatures, but operational questions still required ad-hoc productionqueries. Dashboards were slow, and linked views could not be trusted torefresh from a consistent snapshot.</p><p>Four Go engineers had to build the data platform alongside featuredelivery. We could not operate a separate OLAP database, sync pipeline,and dedicated data engineering function, so we built <ahref="https://docs.pingcap.com/tidb/stable/ticdc-overview">TiCDC</a> →<span class="exturl" data-url="aHR0cHM6Ly9rYWZrYS5hcGFjaGUub3JnLw==">Kafka<i class="fa fa-external-link-alt"></i></span> → <ahref="https://flink.apache.org/">Apache Flink</a> → analytical tables inTiDB, served by QueryHub, a self-developed SQL-template service.</p><span id="more"></span><img data-src="/posts/engineering-case-study/building-a-real-time-data-platform-with-tidb-cdc-and-flink/data-platform-cover.png" class="" title="Real-Time Data Platform architecture cover"><h2 id="context-and-requirements">Context and requirements</h2><p>The backend foundation came from <ahref="/posts/engineering-case-study/scaling-four-engineers-to-eighteen-go-services-with-generated-contracts/">ScalingFour Engineers to Eighteen Go Services with Generated Contracts</a>.From June through November 2025, I <strong>initiated and led</strong>the architecture and rollout for the CDC path, warehouse layering, andquery serving in a separate data-platform workstream. ConfigHub was partof that workstream.</p><p>Product managers needed transaction, settlement, and promotionmetrics. Operations needed to detect abuse while a campaign was stillactive. Finance needed reconciliation. The production schemas wereoptimized for writes, not analytical reads.</p><p>The daily cost appeared in three ways: new metrics required ad-hocproduction queries or a new ETL pipeline; metrics varied by dimension,granularity, and refresh requirement; and T+1 reporting was too slow foran active promotion-abuse investigation.</p><p>The product had a fast feature cadence, correctness-sensitivetransaction flows, and live-ops reports that changed every sprint. Westandardized on TiDB and required:</p><ul><li>Hourly aggregation of transaction, settlement, promotion,referral/commission, and wallet-transfer events.</li><li>Multi-stream joins and dimension-table lookups for detail andwide-table analysis.</li><li>Logical ODS → DWD → DWS layering with explicit ownership.</li><li>Analytics tables updated within seconds rather than waiting for T+1reports.</li><li>Daily and monthly report boundaries in the viewer's timezone.</li><li>Stable pipeline definitions deployed through the existing K8s andCI/CD path.</li><li>Recoverability from TiCDC replay, Flink restarts, and batchcorrections without duplicating aggregate rows.</li></ul><h2 id="options-considered">Options Considered</h2><table><colgroup><col style="width: 25%" /><col style="width: 25%" /><col style="width: 25%" /><col style="width: 25%" /></colgroup><thead><tr><th>Decision</th><th>Alternatives considered</th><th>Selected</th><th>Why</th></tr></thead><tbody><tr><td>Report access</td><td><span class="exturl" data-url="aHR0cHM6Ly9zdXBlcnNldC5hcGFjaGUub3JnLw==">Superset<i class="fa fa-external-link-alt"></i></span>, self-developedQueryHub + existing admin UI</td><td>Self-developed QueryHub + React/ECharts</td><td>Kept dashboards product-owned and inside the admin experience</td></tr><tr><td>Change capture</td><td><span class="exturl" data-url="aHR0cHM6Ly9naXRodWIuY29tL2FsaWJhYmEvY2FuYWw=">Canal<i class="fa fa-external-link-alt"></i></span>, direct FlinkCDC, <ahref="https://docs.pingcap.com/tidb/stable/ticdc-overview">TiCDC</a></td><td>TiCDC → Kafka</td><td>Native TiDB capture; Kafka retained the changefeed while Flinkcheckpoints stored source offsets and job state</td></tr><tr><td>Storage/query</td><td>Separate OLAP store, TiDB + TiFlash</td><td>TiDB + TiFlash</td><td>Reused MySQL compatibility, joins, security, and operationalknowledge</td></tr><tr><td>Processing API</td><td>Custom streaming pipeline, Flink SQL, DataStream</td><td>Flink SQL by default; DataStream for custom state and cleansing</td><td>Reused tested checkpoint, keyed-state, recovery, and backpressureprimitives; kept common reports reviewable</td></tr></tbody></table><p>A custom streaming pipeline could keep business logic close to theservices and avoid adopting another framework. We rejected it because itwould have made the team own checkpoint/recovery behavior, statemigration, event-time ordering, duplicate suppression, scaling,backpressure, and latency tuning. Flink did not remove those concerns,but it provided tested primitives for them. Sink correctness remainedour responsibility.</p><h2 id="architecture-design">Architecture / Design</h2><p>The platform had two table groups—selected production tables andderived analytical tables—and both were queryable through TiDB. Selectedproduction tables were stored in TiKV, with <ahref="https://docs.pingcap.com/tidb/stable/tiflash-overview">TiFlash</a>maintaining asynchronous columnar Raft Learner replicas. For derivedmetrics, TiCDC sent selected changes to Kafka; Flink aggregated them andwrote <code>ads_*</code> tables to TiDB over MySQL/JDBC; TiKV persistedthe rows and TiFlash maintained the serving replicas.</p><p>For the direct replicas, TiKV → TiFlash was a <strong>storage-engineoperation</strong> rather than an external ETL job. One command enabledthe replica:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">ALTER TABLE</span> <span class="operator">&lt;</span>source_table<span class="operator">&gt;</span> <span class="keyword">SET</span> TIFLASH REPLICA <span class="number">1</span>;</span><br></pre></td></tr></table></figure><p>TiFlash then bootstrapped existing rows through a Raft Learnersnapshot and applied subsequent Raft updates. We did not write aseparate full-load/backfill job, store high-watermark offsets, or run anincremental sync process for those replicas. This did not eliminateoperational work: replica progress, storage headroom, IOPS, scans, andcompaction became part of the TiDB capacity model. The TiCDC → Kafka →Flink path remained conventional ETL because it transformed selectedevents into new <code>ads_*</code> aggregates.</p><p>Logically, the warehouse followed ODS → DWD → DWS, but ODS was not acopied store. The selected business tables were the ODS layer; TiCDCread them in place. Flink joined event streams and dimensions intodetail/wide tables for DWD, while SQL jobs materialized hourlyaggregates into <code>ads_*</code> DWS tables. Because all layers livedin TiDB, the layering set naming, ownership, and refresh rules; it didnot require a separate database.</p><img data-src="/posts/engineering-case-study/building-a-real-time-data-platform-with-tidb-cdc-and-flink/architecture-c4.png" class="" title="C4 container view of the real-time data platform: selected production tables as in-place ODS with TiFlash replicas, TiCDC, TiCDC changefeed topics in Kafka, Flink SQL and DataStream jobs, DWD detail and ads_* DWS aggregates, QueryHub, Redis caching, and dashboard users"><p>The captured sources covered transaction settlements, order amounts,referral/commission ledgers, promotion records, and wallettransfers.</p><h2 id="key-technical-decisions">Key Technical Decisions</h2><h3 id="sql-first-processing">SQL-first processing</h3><p>SQL jobs produced hourly aggregate rows keyed by bucket time andbusiness dimensions. A separate DataStream JAR handled workloads thatneeded custom state or event cleaning: it owned user-session statedriven by device and transaction events, and cleansedstandard-operating-procedure (SOP) lifecycle-management data. This keptmost metrics reviewable while isolating timers, keyed state, andchange-only output.</p><h3 id="idempotent-hourly-aggregates">Idempotent hourly aggregates</h3><p>Each hourly aggregate table used the bucket start timestamp plusbusiness dimensions as its primary key. Flink JDBC sinks upserted thosekeys, so early fires, checkpoint restarts, or batch reruns replaced thesame row instead of creating duplicates.</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- Simplified Flink sink shape; several hourly tables followed this pattern.</span></span><br><span class="line"><span class="keyword">CREATE TABLE</span> ads_user_transfer_source_symbol_hourly (</span><br><span class="line">    created_at <span class="type">BIGINT</span> <span class="keyword">NOT NULL</span>,      <span class="comment">-- UTC millisecond bucket start</span></span><br><span class="line">    source     STRING <span class="keyword">NOT NULL</span>,</span><br><span class="line">    symbol     STRING <span class="keyword">NOT NULL</span>,</span><br><span class="line">    amount     <span class="type">DECIMAL</span>(<span class="number">38</span>, <span class="number">18</span>),</span><br><span class="line">    amount_usd <span class="type">DECIMAL</span>(<span class="number">38</span>, <span class="number">18</span>),</span><br><span class="line">    <span class="keyword">PRIMARY KEY</span> (created_at, source, symbol) <span class="keyword">NOT</span> ENFORCED</span><br><span class="line">) <span class="keyword">WITH</span> (</span><br><span class="line">    <span class="string">&#x27;connector&#x27;</span> <span class="operator">=</span> <span class="string">&#x27;jdbc&#x27;</span></span><br><span class="line">);</span><br></pre></td></tr></table></figure><p>Airflow scheduled the H+1/T+1 offline jobs used for reconciliation.The batch lane handled historical backfill and selected incrementalrecovery after a pipeline fault; jobs reread a selected time range andrecalculated the same keys. Streaming and batch paths therefore sharedone serving table; that avoided a separate reconciliation target butincreased contention on the TiDB cluster and required closer attentionto TiFlash sync capacity.</p><h3 id="queryhub-template-service">QueryHub template service</h3><p>I designed and built QueryHub, the SQL-template service. It storednamed SQL templates as versioned configuration. A template carried itsdata source, SQL, parameters, cache policy, status, and version. Queryexecution became a configuration change rather than a new Go handler,route, DTO, and deployment. Reviewed SQL seeds were still required, anda new visualization could still require frontend wiring.</p><p>Redis keys included the template name, sorted-parameter hash, andcache-group version. Hot queries reused results while related templatescould be invalidated together. Reviewed templates exposed metricdefinitions, parameters, cache invalidation, and access asconfiguration.</p><h3 id="timezone-aware-reporting">Timezone-aware reporting</h3><p>Streaming jobs wrote UTC millisecond buckets. QueryHub templatesaccepted a <code>:time_zone</code> parameter, converted reportboundaries to UTC, and grouped stored buckets into viewer-local dates ormonths. Templates generated date/month series, left joined aggregatesonto them, and filled inactive periods with zero. The same physical rowssupported different local reporting boundaries and consistent MoM/YoYcomparisons.</p><h2 id="implementation">Implementation</h2><p>The platform spanned two Kubernetes clusters. TiDB, PD, TiKV, andTiFlash ran in a dedicated TiDB K8s cluster, while TiCDC captured theirchanges into Kafka. Flink and QueryHub ran in the business K8s cluster,in different namespaces. That boundary separated database workloads frombusiness-service deployments, but it did not isolate the analytical pathfrom production TiDB capacity: TiCDC read source tables, Flink andreconciliation wrote derived tables, and TiFlash maintained replicas andserved scans on the same TiDB cluster.</p><table><colgroup><col style="width: 50%" /><col style="width: 50%" /></colgroup><thead><tr><th>Component</th><th>Ownership / role</th></tr></thead><tbody><tr><td>Flink SQL</td><td>Streaming aggregation, multi-stream joins, and reconciliation</td></tr><tr><td>DataStream JAR</td><td>User online/session state and SOP (standard-operating-procedure)lifecycle-management cleansing</td></tr><tr><td>TiDB / PD / TiKV</td><td>MySQL-compatible serving and row storage for production and derivedtables</td></tr><tr><td>TiFlash</td><td>Columnar replicas for analytical queries</td></tr><tr><td>TiCDC</td><td>Change capture into Kafka</td></tr><tr><td>QueryHub</td><td>SQL templates, parameter binding, caching, and query API</td></tr></tbody></table><p>Each metric followed the same rollout path: Flink job,<code>ads_*</code> table, optional TiFlash replica, QueryHub template,and frontend chart mapping.</p><p>For dashboard queries, the admin frontend called QueryHub'snamed-query API through the gateway. QueryHub returned generic tabularrows, which ECharts rendered in the existing React admin.</p><h2 id="when-tiflash-freshness-fell-behind">When TiFlash Freshness FellBehind</h2><p>The first production problem was TiFlash storage sizing. Its datavolumes initially used cloud-volume defaults, which left too littleheadroom for the replica's concurrent workloads. During peak periods,TiDB monitoring raised alerts for replica synchronization lag andrelated freshness metrics; analytical tables also looked stale. Writes,replica maintenance, and analytical reads competed for the smallper-volume IOPS budget.</p><p>TiKV had its own floors: SSD storage (preferably NVMe/PCIe), sinceTiDB writes became Raft-replicated row storage that TiCDC read, andIOPS/throughput headroom for TiKV's RocksDB LSM (Log-StructuredMerge-tree) path — queueing there raised write and Raft latency evenwithout TiFlash contention. The incident surfaced on TiFlash firstbecause columnar replicas added a second storage workload with scans,merge/compaction, and synchronization traffic.</p><p>Each TiFlash data volume moved from the cloud-volume default to40,000 provisioned IOPS. With two TiFlash nodes, the layer had 80,000IOPS in aggregate, but each volume still had an independent budgetrather than a shared cluster-wide pool. After the change, thepeak-period alerts stopped, TiFlash replicas kept up with the streamingpath, and second-level freshness became dependable in normaloperation.</p><p>This choice also changed cost. The TiFlash layer added dedicatednodes and higher per-volume provisioned IOPS, so each analytical replicaincreased baseline storage and IOPS cost as well as query load. We paidfor that choice with more storage headroom and provisioned IOPS ratherthan operating a separate OLAP service.</p><p>Hardware floors alone were insufficient for sizing TiFlash. I wouldsize it separately from TiDB HA node floors, using changed-row volume,replica sync traffic, scan shape, compaction pressure, and freshnesstargets.</p><p>TiFlash also added an operational checklist. Creating an<code>ads_*</code> table did not automatically enable its TiFlashreplica; we ran the replica DDL separately and verified that the tablewas queryable before calling the dashboard production-ready.</p><h2 id="results">Results</h2><p>The platform moved dashboards from ad-hoc production queries to arepeatable pipeline. TiCDC change volume peaked above 1,000 changed rowsper second; that figure was the capacity reference for Kafkapartitioning, Flink parallelism, and TiFlash sizing. Online Grafanadashboards also recorded roughly 10–20 million transaction-related gRPCcalls per day, with peaks of at least 500 calls per second, but thosenumbers measured request pressure rather than TiCDC rows or inserts. TheFlink jobs covered transaction/settlement, promotion, commission-ledger,wallet-transfer, campaign, and online/session data; the Java DataStreamJAR added SOP lifecycle-management cleansing.</p><table><colgroup><col style="width: 33%" /><col style="width: 33%" /><col style="width: 33%" /></colgroup><thead><tr><th>Outcome</th><th>Evidence</th><th>Engineering effect</th></tr></thead><tbody><tr><td>Near-real-time analytics</td><td>Second-level OLTP-to-analytics freshness</td><td>Enabled active-campaign monitoring instead of T+1 review</td></tr><tr><td>Controlled analytical read path</td><td>QueryHub read analytical tables and cached hot queries</td><td>Reduced uncontrolled production-table scans</td></tr><tr><td>Reporting as configuration</td><td>More than 100 parameterized SQL templates</td><td>New query-backed metrics could be added through configuration</td></tr><tr><td>Consistent linked dashboards</td><td>Cache groups and keyed hourly aggregates</td><td>Linked views used the same metric definitions</td></tr><tr><td>Targeted reconciliation</td><td>Batch rewrote the same hourly keys</td><td>Corrected selected ranges without rebuilding serving tables</td></tr><tr><td>Ledger reconciliation</td><td>Covered settlement and commission ledgers</td><td>Supported repeatable reconciliation</td></tr></tbody></table><h2 id="trade-offs">Trade-offs</h2><p>Analytics shared the production TiDB cluster's capacity andoperational path, coupling analytical pressure to productionperformance. QueryHub reduced service-code work, but metric review andfrontend wiring remained necessary.</p><p>I would keep TiCDC → Kafka → Flink and the idempotent TiDB sinks, butsize the shared cluster for analytical work earlier. Managing productionand analytical tables together saved staffing effort; the cost was thatan analytical freshness incident became a shared TiDB capacityproblem.</p><p>Before changing storage, I would keep dashboards on analytical tablesonly, set QueryHub query limits and timeouts, and isolate batchreconciliation windows. The useful alerts group by path: pipeline lagcovers TiCDC lag, Kafka partition skew, and Flink checkpoint duration;the write path covers JDBC upsert rate and TiKV write latency; theserving engine covers TiFlash replica lag, disk IOPS and latency, andcompaction activity. Those groups would localize pressure to capture,processing, writes, or serving rather than only announcing that theshared cluster was busy.</p><p>If analytical scans or production-latency risk kept growing, I wouldmove analytical detail and <code>ads_*</code> serving out of theproduction TiDB cluster while preserving the pipeline. ClickHouse couldsuit sustained detail scans and long retention, but it would changecorrection semantics: the current correction path relies on keyed TiDBupserts, while ClickHouse would require explicit ordering,merge/deduplication, and correction behavior. I would consider thatmigration when sustained scan cost, retention, and query concurrencyjustified it.</p><hr /><p><em>Previous in this series: <ahref="/posts/engineering-case-study/scaling-four-engineers-to-eighteen-go-services-with-generated-contracts/">ScalingFour Engineers to Eighteen Go Services with GeneratedContracts</a></em>.</p><p><em>Next in this series: <ahref="/posts/engineering-case-study/building-a-predicate-driven-audience-pipeline-for-lifecycle-email/">Buildinga Predicate-Driven Audience Pipeline for Lifecycle Email</a></em>.</p>]]>
    </content>
    <id>https://edwardshen125.github.io/posts/engineering-case-study/building-a-real-time-data-platform-with-tidb-cdc-and-flink/</id>
    <link href="https://edwardshen125.github.io/posts/engineering-case-study/building-a-real-time-data-platform-with-tidb-cdc-and-flink/"/>
    <published>2026-02-07T04:00:00.000Z</published>
    <summary>How a four-engineer backend team built a TiCDC, Kafka, and Flink data platform on TiDB for second-level analytics, reconciliation, and QueryHub dashboards.</summary>
    <title>Building a Real-Time Data Platform With TiDB CDC and Flink</title>
    <updated>2026-10-15T17:10:07.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Edward Shen</name>
    </author>
    <category term="Engineering Case Study" scheme="https://edwardshen125.github.io/categories/Engineering-Case-Study/"/>
    <category term="go" scheme="https://edwardshen125.github.io/tags/go/"/>
    <category term="microservices" scheme="https://edwardshen125.github.io/tags/microservices/"/>
    <category term="api-gateway" scheme="https://edwardshen125.github.io/tags/api-gateway/"/>
    <category term="platform-engineering" scheme="https://edwardshen125.github.io/tags/platform-engineering/"/>
    <category term="developer-productivity" scheme="https://edwardshen125.github.io/tags/developer-productivity/"/>
    <content>
      <![CDATA[<p>Four Go engineers built eighteen domain services for a consumertransaction platform with weekly live-ops releases — time-boxed in-gameevents and promotions whose cadence set the delivery rhythm. To keepintegration work manageable, we used a single API DSL to generateservice conventions, <span class="exturl" data-url="aHR0cHM6Ly9wcm90b2J1Zi5kZXYv">ProtocolBuffers<i class="fa fa-external-link-alt"></i></span> contracts, and <ahref="https://swagger.io/specification/">Swagger/OpenAPI</a>documentation.</p><span id="more"></span><img data-src="/posts/engineering-case-study/scaling-four-engineers-to-eighteen-go-services-with-generated-contracts/cover.png" class="" title="Go Microservice Platform cover: clients, API gateway, domain services, Kafka, ws-hub, TiDB"><h2 id="context-and-requirements">Context and requirements</h2><p>The product team was assembled from scratch in early 2025 withseventeen people: four Go engineers building the backend, six frontenddevelopers, two QA, two UI designers, and three product managers drivingthe live-ops roadmap. That roadmap required weekly feature releasesrather than a long platform-build cycle. I <strong>initiated andled</strong> the platform program that ran from February to May: anarchitecture template, a contract toolchain, a shared gateway, baselibraries, and CI, all built in parallel with the first businessservices. The platform then became the default path across identity andaccess, financial transactions and ledger integrations, consumerengagement, growth and live-ops, and platform infrastructure. Therepository statistics cover the eighteen services; the shared APIgateway and one tooling repository sit outside that count.</p><p>The same four engineers had to ship against money-movement flows andaround-the-clock availability while the platform was being built. Wealso made one choice early: standardize on <ahref="https://github.com/zeromicro/go-zero">go-zero</a> rather thanbuild another framework. We used the platform to encode conventionswhile relying on go-zero for runtime infrastructure.</p><p>The release-unit boundary followed a real difference in changefrequency. Live-ops code changed weekly by design: time-boxed events,promotions, and campaign pages moved with the product roadmap. Ledgerand identity code changed less often and required more conservativereview. Keeping them in separate deployable units meant a weeklyactivity release did not require redeploying the money path or theauthentication path. That principle set the coarse release boundaries;the remaining domain split was an ownership and evolution choice, andthe platform's contract, generation, and default-runtime mechanisms keptthe resulting governance cost manageable.</p><p>Each service needed auth, error codes, structured logging, RPCwiring, gateway registration, and tracing. Maintaining these by handacross the service fleet would consume integration time and letimplementations diverge. The platform needed:</p><ul><li>One contract entry per service, from which handlers, RPC, and docsall derive.</li><li>A scaffold that generated the required architecture.</li><li>Platform-wide error codes, auth, and context propagation aslibraries.</li><li>Gateway-managed response caching for consumer-facing interfaces,configured at the interface level rather than rebuilt inside eachservice.</li><li>CI and tracing by default on every service.</li><li>Generated service shells with production defaults from their firstcommit.</li><li>An onboarding cost we could measure.</li></ul><h2 id="options-considered">Options Considered</h2><p>The main choice was the contract direction. go-zero already providedservice discovery and goctl, its API-first code generator; the team knewit well. The alternative to goctl's <code>.api</code> DSL wasproto-first: define <span class="exturl" data-url="aHR0cHM6Ly9ncnBjLmlvLw==">gRPC<i class="fa fa-external-link-alt"></i></span> contracts andderive HTTP from them.</p><p>Proto-first would have made proto the canonical internal contract,but the client-facing HTTP route, request shape, auth annotation, cacheoption, or Swagger document still needed a second definition. That wasthe <strong>double-contract drift</strong> we wanted to remove. It alsowould have pushed us toward maintaining more of the generation chainourselves. DSL-first gave up some proto-level control: services thatneeded advanced gRPC features fell back to hand-written proto. Weaccepted that <em>rare exception</em> because one <code>.api</code> filecould remain the entry for handlers, generated proto, and Swagger.</p><h2 id="architecture-design">Architecture / Design</h2><p>The <span class="exturl" data-url="aHR0cHM6Ly9jNG1vZGVsLmNvbS8=">C4<i class="fa fa-external-link-alt"></i></span> container view below showsthe platform as it runs today. Clients speak HTTPS to a single APIGateway, which routes to domain services over gRPC using Protocol Bufferdescriptor sets generated from the shared contract repository;Kubernetes Endpoints resolves live addresses. Services keep per-serviceschemas in <ahref="https://docs.pingcap.com/tidb/stable/overview">TiDB</a>, cache inRedis, and export spans to <span class="exturl" data-url="aHR0cHM6Ly96aXBraW4uaW8v">Zipkin<i class="fa fa-external-link-alt"></i></span>. Foruser-facing pushes, services publish to Kafka and ws-hub fansnotifications out over websockets. An i18n hook on the gateway responsechain localizes dynamic response content, so services staylocale-blind.</p><img data-src="/posts/engineering-case-study/scaling-four-engineers-to-eighteen-go-services-with-generated-contracts/architecture-c4.png" class="" title="C4 container view of the Go Microservice Platform: clients, API gateway with jwt, i18n, and configurable response caching, seventeen collapsed domain services plus ws-hub, TiDB per-service schemas, Redis caching, Kafka event publishing, websocket fan-out, and Zipkin tracing"><h2 id="key-technical-decisions">Key Technical Decisions</h2><h3 id="dsl-as-the-single-contract-entry">DSL as the single contractentry</h3><p>A single authentication <code>.api</code> file carried the routes,types, and three access-control tiers:</p><figure class="highlight pgsql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line">syntax = &quot;v1&quot;</span><br><span class="line"></span><br><span class="line">// <span class="built_in">public</span> <span class="keyword">login</span></span><br><span class="line">@<span class="keyword">server</span>(</span><br><span class="line"> prefix: /v1/<span class="keyword">admin</span>/auth</span><br><span class="line">)</span><br><span class="line">service AdminAuthService &#123;</span><br><span class="line"> @<span class="keyword">handler</span> <span class="keyword">Login</span></span><br><span class="line"> post /<span class="keyword">login</span> (LoginReq) <span class="keyword">returns</span> (LoginRes)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">// permission-gated <span class="keyword">reset</span> (auth: <span class="keyword">admin</span> sits <span class="keyword">between</span> these two tiers)</span><br><span class="line">@<span class="keyword">server</span>(</span><br><span class="line"> prefix: /v1/<span class="keyword">admin</span>/auth</span><br><span class="line"> auth: &quot;admin=permissionUserResetPwd&quot;</span><br><span class="line">)</span><br><span class="line">service AdminAuthService &#123;</span><br><span class="line"> @<span class="keyword">handler</span> ResetPassword</span><br><span class="line"> post /reset_password (ResetPasswordReq) <span class="keyword">returns</span> (ResetPasswordRes)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Handlers and types came from goctl; our plugins generated proto andSwagger from the same source. There was no second route definition todrift from.</p><p>The contract repository held fifty-five <code>.api</code> contractfiles and sixty-eight generated proto schemas. Between March 4 andOctober 9, 2025, it absorbed 3,568 commits — more than any singleservice — with four hundred to seven hundred landing every month. Everyinterface change in the platform flowed through that one repository.</p><h3 id="fill-the-generation-gaps-with-plugins">Fill the generation gapswith plugins</h3><p>Vanilla goctl generated HTTP handlers from <code>.api</code> but notproto or Swagger. We added goctl-proto and goctl-swagger plugins so one<code>.api</code> file produced all three in the same commit.</p><h3 id="the-scaffold-as-specification">The scaffold asspecification</h3><p>The onion/DDD structure lived in the README and custom goctltemplates. A generated service started with the layering, outbox, andrepository seams already in place, before business code was added.</p><h3 id="transactional-domain-events-through-the-outbox">Transactionaldomain events through the outbox</h3><p>Services persisted domain events in an outbox table within the sameTiDB transaction as the business-state change. TiCDC captured committedoutbox rows and delivered them to Kafka as <code>domain_event_*</code>topics. This kept event persistence consistent with the businesstransaction and removed the gap between committing data and publishingan event. Delivery remained asynchronous, and consumers still neededidempotent handling on replay.</p><p>Low-latency ws-hub notifications were published directly to Kafka.Transactional domain events followed the outbox path.</p><h3 id="domain-boundaries-and-data-ownership">Domain boundaries and dataownership</h3><p>All eighteen services shared one TiDB cluster, so the boundary wasdrawn at the table level. The user service owned auth, KYC, andcustomer-profile tables; the ledger service owned currency orders,account balances, and token metadata. Cross-service access ran overgRPC, and a repository-layer scan confirmed zero cross-domain tableaccess. Disjoint table ownership preserved service boundaries withoutoperating eighteen databases.</p><p>These were ownership and change boundaries rather than eighteenindependent failure domains: the services shared one TiDB cluster andthe platform's gateway, Kafka, and tracing infrastructure, so acluster-level failure or a problematic migration could still affectmultiple domains.</p><h3 id="platform-wide-error-codes">Platform-wide error codes</h3><p>bizerr encodes every error as a six-digit code: two digits of serviceID, two of function, two of error. Clients match on the prefix; anyonecan decode an error to its origin without grep. Errors carry capturedstacks and wrap their causes, so production incidents could be tracedthrough the chain without re-running anything. The registry doubles asthe coordination point — a new service claims its service ID once, andan unregistered code causes a runtime panic.</p><p>That panic treated an unregistered code as a <strong>programmingerror</strong> rather than an operational error. It was intended to failin the first test run instead of allowing an error with no message,locale, or service origin to reach a user. The cost was that an untestedpath could still become a production failure, so centralizedregistration and error-path tests mattered; a build-time registry checkwould have been the better enforcement point.</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// each service claims an id; codes are registered with metadata</span></span><br><span class="line"><span class="keyword">const</span> errUserNotFound = bizerr.ErrCode(<span class="number">120401</span>)</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">init</span><span class="params">()</span></span> &#123;</span><br><span class="line"> bizerr.RegisterErrorCode(errUserNotFound, bizerr.ErrorMeta&#123;</span><br><span class="line"> DefaultMessage: <span class="string">&quot;user not found&quot;</span>,</span><br><span class="line"> &#125;)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// call sites build from registered codes only —</span></span><br><span class="line"><span class="comment">// an unregistered code panics before it can reach a user</span></span><br><span class="line"><span class="keyword">return</span> errUserNotFound.Build(err)</span><br></pre></td></tr></table></figure><h3 id="gateway-as-the-composition-edge">Gateway as the compositionedge</h3><p>The gateway wrapped a maintained go-zero fork and owned everythingcross-cutting: JWT auth, header processing, response wrapping, CORS,response caching, and an i18n hook on the response chain that localizesresponse content per user locale. Consumer-facing interfaces enabledcaching through gateway configuration, so a service did not have toimplement its own response-cache path. Because localization also livedin the gateway, services stayed locale-blind; adding a language requiredone gateway change.</p><p>Keeping these authentication and edge policies in one gatewayimplementation also reduced security-policy drift across services. Eachservice still enforced its own domain authorization, while route-levelauthentication and cache policy came from the same contract-drivenconfiguration path. That made the boundary easier to review and reducedthe chance that independently implemented services would applyinconsistent rules.</p><p>The cache setting stayed in the same interface contract. Aconsumer-facing banner route looked like this:</p><figure class="highlight less"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable">@server</span>(</span><br><span class="line"> <span class="attribute">prefix</span>: /v1/banner/noauth</span><br><span class="line"> <span class="attribute">auth</span>: none</span><br><span class="line"> <span class="attribute">i18n</span>: enabled</span><br><span class="line">)</span><br><span class="line">service BannerService &#123;</span><br><span class="line"> <span class="variable">@doc</span>(</span><br><span class="line"> <span class="attribute">summary</span>: <span class="string">&quot;get banner list for web&quot;</span></span><br><span class="line"> <span class="attribute">cache</span>: <span class="string">&quot;expiresIn=300&quot;</span></span><br><span class="line"> )</span><br><span class="line"> <span class="variable">@handler</span> GetBannerListForWeb</span><br><span class="line"> post /get/banner/list (GetBannerListRequest) returns (GetBannerListResponse)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The proto plugin emitted this route as a per-method cache option witha 300-second TTL and <code>CACHESCOPE_GLOBAL</code>. At startup, thegateway read the descriptor and installed Redis cache middleware onlyfor routes with a positive TTL. The key covered the HTTP method, path,query, JSON body, <code>Accept-Language</code>, and<code>User-Agent</code>. On protected routes, JWT ran before cache, sothe authenticated provider and user IDs also entered the key. Anauthenticated request was therefore isolated even when the annotationrequested a global cache.</p><p>The i18n hook localized the CMS's dynamic content — productdescriptions, promotions, and operational copy — per user locale,managed by operations through the language service. Error messages tooka different path by design: services return language-neutral errorcodes, and the frontend maps each code to a localized string through itsown static i18n library, avoiding a backend round-trip for errorlocalization.</p><h3 id="shared-generation-and-ci-templates">Shared generation and CItemplates</h3><p>Every convention that could be enforced by generation was enforcedthere: custom goctl templates generated the onion structure, makefiletargets pinned the base-library set, and shared CI templatesstandardized pipelines for Go, React, and Next.js services.</p><h3 id="runtime-and-deployment-choices">Runtime and deploymentchoices</h3><p>We skipped Istio because go-zero already carried service discoveryand the microservice mechanics we needed. We used the Kubernetes APIinstead of a custom deployment system, kept the maintained go-zero forkinstead of a new RPC framework, and left early configuration inConfigMap until business operations needed config-hub's runtimechanges.</p><h2 id="implementation">Implementation</h2><p>A generated service started with an <code>.api</code> skeleton, oniondirectory tree, Dockerfile, and deployment manifests. Its buildreferenced the six base libraries, the go-zero fork, and service SDKs;shared GitLab includes supplied Go, React, and Next.js pipelines. Zipkintracing was a config line. The gateway loaded method descriptors atstartup and resolved backends through Kubernetes Endpoints, so addingpods did not require gateway redeployment.</p><p>Pod rescheduling, ws-hub reconnection, and gateway instance failurepaths were designed but never systematically tested under failureconditions. A chaos suite that restarts gateways, interrupts servicediscovery, and exercises ws-hub reconnection under load would validatethose assumptions.</p><h2 id="adoption">Adoption</h2><p>The scaffold had to stay ahead of the services while the four-personteam shipped features. The template evolved with the first services;every later service generated from it. The only non-generated conventionwas claiming a service ID in the bizerr registry.</p><p>Once the scaffold and DSL stabilized, team members started writingbusiness logic with AI coding agents while feature delivery continued.The contract defined interfaces, the generated tree supplied thelayering, and the error registry defined failure shapes, constrainingthe code agents produced.</p><h2 id="where-the-defaults-leaked">Where the Defaults Leaked</h2><h3 id="a-contract-change-reached-qa">A contract change reached QA</h3><p>The <code>.api</code> files had no change monitoring. When oneinterface definition changed, no consumer saw the generated proto orSwagger diff before merge. QA's automated consumer-call tests caught theincompatible change before release. A CI step that diffs generated protoand Swagger against the previous version and posts the delta wouldexpose these changes before merge.</p><h3 id="an-admin-editor-crossed-four-contracts">An admin editor crossedfour contracts</h3><p>One admin editor exposed the missing composition boundary. A singlepage called content/activity, catalog, payment-metadata, andlocalization services directly. It loaded catalog providers, playableitems, token metadata, and localized components. On save, the frontendfirst upserted localized copy, read back generated references, mergedthem into the business payload, and then called the business create orupdate API.</p><p>The browser owned the cross-service write sequence. If localizationsucceeded but the business write failed, localized components remainedwhile the business record did not. Several admin pages alsoindependently fetched catalog providers, payment metadata, or localizedlabels, so a small visible change could cross multiple domaincontracts.</p><p>For this editor, I would add a BFF endpoint that owns the page shapeand localization-plus-business write sequence, including compensationand idempotency. Domain services would retain content, catalog, payment,and localization ownership. Other admin pages could use the sameboundary instead of assembling four separate contracts in thefrontend.</p><h2 id="results">Results</h2><p>All eighteen Go domain services came from the scaffold. Rare advancedgRPC cases used hand-written proto, but no service was hand-rolledoutside the template.</p><p>The estimated development-cost result was a 60% reduction inonboarding work, measured in boilerplate lines and person-days to afirst deployed endpoint. It was <em>an internal estimate, not acontrolled experiment</em>.</p><p>The platform took four months of focused work alongside the firstbusiness services. It replaced days of recurring boilerplate per servicewith a generation command and shared outbox and error-registryconventions for services handling money. The same scaffold supported theJune and July live-ops expansion.</p><p>The engineering result was a governable form of domain decompositionfor a four-engineer backend team. Contract-first design, codegeneration, and platform defaults kept the cost of repeated servicesetup, contract coordination, and shared conventions under control. Theevidence measures governance and delivery cost; it does not claim thatone service topology is universally better than another.</p><h2 id="trade-offs">Trade-offs</h2><p>The platform reduced repeated setup, but it added its own costs. Themaintained go-zero fork was a permanent maintenance duty; we took fixeson our schedule, not upstream's. Onion layering added indirection forthin CRUD services even when generated, and the central error-coderegistry required coordination so two services could not silently claimthe same ID.</p><p>This investment was harder to justify on a short product runway orwith only a few services. The platform's role was to make domaindecomposition affordable: release boundaries followed ownership andchange frequency, while generation kept repeated service setup andshared conventions manageable.</p><p>The remaining gaps were at the cross-service seams: contract changeshad no generated diff visible to consumers, and one admin page composedfour domain contracts in the browser. Generation made per-servicedefaults cheap; it did not replace consumer-visible contract checks or aproper composition boundary.</p><hr /><p><em>Previous in this series: <ahref="/posts/engineering-case-study/debugging-a-sporadic-go-oom-in-a-recursive-json-encode-path/">Debugginga Sporadic Go OOM in a Recursive JSON Encode Path</a></em>.</p><p><em>Next in this series: <ahref="/posts/engineering-case-study/building-a-real-time-data-platform-with-tidb-cdc-and-flink/">Buildinga Real-Time Data Platform with TiDB CDC and Flink</a></em>.</p>]]>
    </content>
    <id>https://edwardshen125.github.io/posts/engineering-case-study/scaling-four-engineers-to-eighteen-go-services-with-generated-contracts/</id>
    <link href="https://edwardshen125.github.io/posts/engineering-case-study/scaling-four-engineers-to-eighteen-go-services-with-generated-contracts/"/>
    <published>2025-10-10T04:00:00.000Z</published>
    <summary>How four Go engineers used a shared API DSL, code generation, and platform defaults to deliver eighteen production services while controlling contract drift.</summary>
    <title>Scaling Four Engineers to Eighteen Go Services With Generated Contracts</title>
    <updated>2026-10-16T13:46:35.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Edward Shen</name>
    </author>
    <category term="Engineering Case Study" scheme="https://edwardshen125.github.io/categories/Engineering-Case-Study/"/>
    <category term="distributed-systems" scheme="https://edwardshen125.github.io/tags/distributed-systems/"/>
    <category term="go" scheme="https://edwardshen125.github.io/tags/go/"/>
    <category term="tidb" scheme="https://edwardshen125.github.io/tags/tidb/"/>
    <category term="pulsar" scheme="https://edwardshen125.github.io/tags/pulsar/"/>
    <category term="etcd" scheme="https://edwardshen125.github.io/tags/etcd/"/>
    <category term="scheduling" scheme="https://edwardshen125.github.io/tags/scheduling/"/>
    <content>
      <![CDATA[<p>The legacy batch center and <ahref="https://github.com/xuxueli/xxl-job">xxl-job</a> had reached theirdispatch ceiling, and every new instance multiplied lock contention onthe same task table. We replaced them with a scheduling center on <ahref="https://docs.pingcap.com/tidb/stable/overview">TiDB</a>, <ahref="https://etcd.io/">etcd</a>, and <ahref="https://pulsar.apache.org/">Apache Pulsar</a>. Its on-the-hourworkload peaked above 200K execution commands per second; productionincidents later led us to revise its isolation model.</p><span id="more"></span><h2 id="context">Context</h2><p>At my previous company, the SCRM (Social CRM) platform servedenterprise marketing teams; its product ran on delayed execution — SOP(standard-operating-procedure) sequences that move a customer throughtimed marketing steps, follow-up reminders, and large one-shot jobs. Twolegacy components, the in-house batch center and xxl-job, covered morethan twenty business scenarios with a continuously active schedulingworkload.</p><p>I <strong>designed and delivered</strong> the Go scheduling centerand migrated existing scenarios onto it, reusing TiDB, etcd, and Pulsarfor storage, coordination, and delivery.</p><p>Four terms recur below. A <em>job</em> was a registered businessdefinition. A <em>trigger</em> was the durable schedule for that job: aone-shot trigger represented one run, while a cron trigger maintainedthe next occurrence in <code>next_fire_at</code>. A <em>shardingitem</em> was a fan-out unit inside a run. An <em>execution command</em>was one sharding item handed to the executor through Pulsar. Throughputcounts published commands, not database scans, trigger rows, ordownstream handler completion.</p><h2 id="problem">Problem</h2><p>Both legacy systems shared the same execution model. When a task camedue, the scheduler dispatched it onto a worker and invoked a businessHTTP callback; the worker stayed occupied until the callback returned.Real handlers took milliseconds to minutes. Under load, the poolsaturated and production hit a measurable dispatch ceiling.</p><p>Scaling out made contention worse: every instance polled and updatedthe same task table, so adding instances multiplied lock contention.</p><pre class="mermaid" style="text-align: center;">                        flowchart LR    L["legacy scheduling load"]:::trigger    D([task becomes due]):::trigger    W["worker pool"]:::state    C["business HTTP callback:<br/>milliseconds to minutes"]:::pressure    L --> D    D --> W    W --> C    C -. holds worker until return .-> W    W --> S["pool saturated;<br/>dispatch delayed"]:::impact    classDef trigger fill:#eef7ff,stroke:#245a7d,stroke-width:2px;    classDef state fill:#f5f5f5,stroke:#4b5563,stroke-width:2px;    classDef pressure fill:#fff7e6,stroke:#8a6d3b,stroke-width:2px;    classDef impact fill:#fdecea,stroke:#a44a3f,stroke-width:2px;          </pre><h2 id="constraints-and-requirements">Constraints and requirements</h2><ul><li>Task state had to live in a transactional database. Tasks werecoupled to business flows, and we needed state transitions to beauditable and recoverable, not buried inside message queues orcaches.</li><li>Existing business scenarios had to migrate without rewriting theirbusiness handler logic. The new system still needed an SDK and a datamigration path for integration.</li><li>Downstream handler latency was unpredictable and outside ourcontrol. The scheduler had to make progress regardless of how longbusiness callbacks took.</li><li>Both old and new systems had to keep serving production trafficduring a gradual, verifiable per-business cutover that fully replacedthe batch center and xxl-job.</li><li>Second-level scheduling precision for delayed tasks, plus cronsupport for recurring ones.</li><li>Peak publish throughput well beyond the old ceiling.</li><li>Consumer isolation between business scenarios from the start;producer isolation became a requirement after the shared-topicincident.</li></ul><h2 id="options-considered">Options Considered</h2><p>We evaluated where to keep durable state, how to shard scheduling,and how to provide delivery precision.</p><h3 id="patch-and-scale-the-legacy-schedulers">Patch and scale thelegacy schedulers</h3><p>Adding instances or enlarging worker pools preserved theworker-per-callback model, and every scheduler still polled and updatedthe same task table. Sharding or partitioning that table was not drop-ineither: the legacy dispatch loop would need a rewrite without removingslow-handler occupancy.</p><h3 id="make-pulsar-the-task-store">Make Pulsar the task store</h3><p>Holding every future trigger as a delayed message would removedatabase polling, but pause, cancel, migration, audit, and recoverywould become queue mutations. Long-lived business state would no longerbe auditable in one transactional place.</p><h3 id="use-etcd-to-shard-schedulers">Use etcd to shard schedulers</h3><p>An etcd coordinator could distribute partitions across nodes, but thebottleneck was TiDB partition access and command delivery, not thenumber of leader processes. Shard assignment, rebalance, andpartial-failure handling would add complexity before removing thatbottleneck.</p><h3 id="hybrid-state-and-delivery">Hybrid state and delivery</h3><p>TiDB remained the transactional owner of trigger state and due-timediscovery. Pulsar delivered only admitted execution commands. etcdsupplied election leases without coordinating scheduler shards.</p><h2 id="architecture-design">Architecture / Design</h2><p>The <span class="exturl" data-url="aHR0cHM6Ly9jNG1vZGVsLmNvbS8=">C4<i class="fa fa-external-link-alt"></i></span> container view below showsthe final system after topic isolation: etcd election; the leader'spartition schedulers and in-process fire pipeline; the TiDB triggerstore; per-domain command topics; a separate callback topic; and theexecutor SDK. Arrow badges carry the protocol for each hop — lease, SQLCAS, deliver-at, consumer groups, or callback event.</p><img data-src="/posts/engineering-case-study/building-a-production-go-scheduler-for-200k-delayed-jobs-per-second/architecture-c4.png" class="" title="C4 container view of the final Lightweight Scheduling Center: etcd election, scheduler leader and in-process fire pool, CAS acquire against the TiDB trigger store, delivery-at publication to per-domain Pulsar command topics, executor SDK consumer groups, a separate Pulsar callback topic, and callback consumption by the scheduler"><p>Reusing those systems kept the scheduler and executor SDKimplementation to roughly 8,900 lines across 110 non-test Go files.</p><h2 id="key-technical-decisions">Key Technical Decisions</h2><h3 id="leader-election-and-acquire-loop-ownership">Leader election andacquire-loop ownership</h3><p>The elected leader owned every TiDB partition scheduler and thebounded fire pool. This kept partition ownership and failure semanticssimple: if the leader died, followers campaigned with exponentialbackoff and one took over. The cost was a one-process acquire-loopceiling and a brief failover gap.</p><p>At larger scale, I would add an etcd-backed shard registry: eachscheduler pod would lease a subset of TiDB <code>HASH(id)</code>partitions and own their acquire/fire pipeline, while etcd watched podliveness and reassigned partitions after failure. This would leavepressure on TiDB and Pulsar but remove the single-pod admission ceilingonce partition count or on-the-hour bursts exceeded one pod'scapacity.</p><h3 id="partition-serial-scheduling">Partition-serial scheduling</h3><p>The trigger table was hash-partitioned by ID in TiDB, with thepartition count sized to expected business volume. The schedulerdiscovered partitions from <ahref="https://docs.pingcap.com/tidb/stable/partitioned-table">tablemetadata</a> and ran exactly one scheduling goroutine per partitioninside the leader. Each goroutine queried its partition explicitly, soscan and admission were serial within that ownership range; the admittedbatch was then handed off to the shared fire pool. This avoidedconcurrent acquire loops competing for the same partition, though TiDBstill shared global storage capacity. Explicit partition selection kepteach scan bounded to one scheduler; hash partitioning did not balanceload by due time.</p><h3 id="cas-as-the-admission-boundary">CAS as the admissionboundary</h3><p>Persistent transitions used conditional updates.<code>waiting -&gt; acquired</code> gated fire preparation;<code>acquired -&gt; waiting</code> or<code>acquired -&gt; completed</code> recorded the outcome. The domainmodel used <code>triggered</code> only as a transient state whilepreparing a fire batch; it was not separately persisted. CAS admittedeach row exactly once at the database boundary, but it did not fence aprevious leader that had already acquired rows — executor idempotencyand an explicit recovery contract were still required. Pause and resumemoved triggers through the same conditional-update API, so a manualoperation could not leave a state the scheduler would misread.</p><p>CAS also could not commit the TiDB state transition and Pulsarpublication as one transaction. A leader could fail after acquiring arow, or after publishing a command but before recording its terminaloutcome. Recovery therefore had to treat execution as at-least-once, andexecutor idempotency—not cross-store atomicity—closed the failurewindow.</p><pre class="mermaid" style="text-align: center;">                        flowchart TD    R([trigger created]):::event --> W([waiting]):::state    W -->|CAS acquire| A([acquired]):::state    A --> F[fire prepared]:::event    F -->|one-shot complete| C([completed]):::state    F -->|cron has next| N[cron next fire]:::event    N --> W    A -.->|dispatch failed| D[dispatch failed]:::event    D -.->|release| W    W -->|pause| P([paused]):::state    P -->|resume| W    classDef state fill:#eef7ff,stroke:#245a7d,stroke-width:2px;    classDef event fill:#fff7e6,stroke:#8a6d3b,stroke-dasharray:3 3;          </pre><h3 id="coarse-acquisition-precise-delivery">Coarse acquisition, precisedelivery</h3><p>The scheduler admitted triggers from a configurable lookahead andpublished each command with a deliver-at time equal to the trigger'sfire time. After the first on-the-hour contention incident, productionused a 30-minute window. This let DB polling be lazy and batchy whilePulsar preserved second-level delivery precision.</p><p>For one trigger, <code>T</code> was its scheduled trigger time, notthe acquire or publish time: <code>T = trigger_at = next_fire_at</code>in TiDB and <code>T = deliver-at</code> in Pulsar.<code>Δ = T - acquire_time</code> was its remaining lead time. In thediagram, <code>T - 30m 30.5s</code> and <code>T + 2h</code> arewall-clock offsets from <code>T</code>; the maximum early-admission leadis <code>30m 30.5s</code> (30s idle wait + 30m acquisition window + 0.5sjitter). The diagram shows the normal early-admission path, where<code>Δ</code> was positive.</p><pre class="mermaid" style="text-align: center;">                        sequenceDiagram    participant L as Lookahead    participant S as Scheduler    participant DB as TiDB    participant Q as Pulsar    participant E as Executor SDK    Note over L,S: Maximum early admission: T - 30m 30.5s    L->>S: trigger enters acquisition window    S->>DB: CAS acquire<br/>waiting -> acquired    S->>Q: publish command<br/>deliver-at = T    Note over Q,E: Pulsar holds command until T    Q->>E: deliver command at T    Note over S,DB: Misfire horizon extends to T + 2h          </pre><p>This split the timing contract: TiDB retained the long-horizon<code>waiting</code> row; Pulsar held only the admitted slice anddelivered it at <code>next_fire_at</code>.</p><p>The same failure window applied to leader election: an<code>acquired</code> row had uncertain publication state. I wouldpersist an acquisition lease or epoch with every <code>acquired</code>row and define its timeout transition, so a new leader could distinguishan owner that might still be publishing from work it could safelyreclaim.</p><h3 id="fan-out-through-sharding-items">Fan-out through shardingitems</h3><p>The largest audience jobs did not expand into one trigger peraudience member. Instead, each job carried sharding items that the firepipeline paged through (300 per page), turning each item into its ownexecution command. Fired records were persisted per item so executionresults could be tracked and aggregated through callbacks.</p><h3 id="sdk-execution-instead-of-exposed-http-callbacks">SDK executioninstead of exposed HTTP callbacks</h3><p>Legacy business services exposed HTTP callback endpoints; everyintegration needed a route, network ingress, and scheduler-to-businessauthorization. With the SDK, a service consumed its Pulsar command topicand invoked a registered function-level callback, so teams implementedhandler logic rather than controller endpoints. The SDK then publishedan execution result to a separate Pulsar callback topic, which thescheduling center consumed to update fired-record results. Broker andSDK identity still required authorization, but the scheduler-to-businessHTTP surface disappeared.</p><h2 id="implementation">Implementation</h2><p>Within the leader, each partition scheduler handed acquired batchesto a bounded in-process fire pool. The fire pool provided up to 6,000concurrent batch workers and served as the scheduler-side backpressureboundary. Its acquire loop ran once per tick:</p><ol type="1"><li>Compute the acquisition window: from the misfire floor — the olderof the partition's tracked latest <code>next_fire_at</code> and twohours before now — to now plus the lookahead. The startup floor couldreach seven days back.</li><li>Select <code>waiting</code> triggers in that window, ordered by duetime, limited to the batch size — and capped by available capacity inthe fire pool, so the scheduler never acquired more than it could handoff.</li><li>CAS the batch from <code>waiting</code> to <code>acquired</code>,confirm winners by re-reading state, then submit groups of up to 100acquired triggers to the fire pipeline with retries.</li><li>If the fire pipeline rejected work repeatedly, release the triggersback to <code>waiting</code> so nothing was lost.</li></ol><p>Production used a 30-minute acquisition window, a 30-second idlewait, up to 0.5 seconds of jitter, a two-hour misfire threshold, and astartup floor that could reach seven days back. A batch was capped at300 triggers and submitted in chunks of 100.</p><p>The repository selected only the bounded due-soon slice from oneexplicitly chosen partition:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">FROM</span> dus_job_trigger <span class="keyword">PARTITION</span> (?)</span><br><span class="line"><span class="keyword">WHERE</span> state <span class="operator">=</span> <span class="string">&#x27;waiting&#x27;</span></span><br><span class="line">  <span class="keyword">AND</span> next_fire_at <span class="keyword">BETWEEN</span> ? <span class="keyword">AND</span> ?</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> next_fire_at <span class="keyword">ASC</span></span><br><span class="line">LIMIT ?</span><br></pre></td></tr></table></figure><p>The SQL admitted by due time first. Trigger priority then acted as anin-memory tie-breaker when selected triggers shared the same<code>next_fire_at</code>; it was not a separate scheduling lane.</p><p>After CAS admission, the fire pipeline computed the next fire time ormarked completion, expanded sharding items, persisted fired records, andpublished each command with <code>deliver-at = fire time</code> and ajob-code tag. Each business SDK consumed its domain command topic,invoked the registered callback, and published the result to thecallback topic.</p><p>Purpose-built readers read old batch task tables and their changestreams; a data flag made repeated migration idempotent. The SDK alsoemitted per-business migration telemetry: consumer lag, call volume, andexecution success rate. For important scenarios whose handlers werealready idempotent, we dual-ran the old and new paths and watched thosesignals before cutover. After load and functional tests againstproduction traffic, migration proceeded from low-risk to criticalscenarios.</p><h2 id="failure-modes-production-challenges">Failure Modes / ProductionChallenges</h2><h3 id="hot-minute-contention">Hot-minute contention</h3><p>The first production stress pattern came from jobs scheduled at thesame time. Hash partitioning spread trigger rows evenly by ID, but alarge SOP workload placed many trigger runs at exact hour boundaries.Just before those boundaries, the acquisition scan found the burst andthe partition-serial path became dominated by that workload. Othertriggers behind it in the same partitions waited; the lookahead fellbehind and the system spent subsequent ticks catching up.</p><p>Monitoring exposed the falling lookahead. We increased theacquisition window to 30 minutes, worked with the business team toreplace exact <code>:00</code> fire times with randomized seconds, andused trigger priority as a same-time tie-breaker. Because SQL stilladmitted by due time first, priority did not fully isolate workloads.Scheduled delivery stayed on time; remaining pressure moved todownstream consumers.</p><p>I would separate on-the-hour SOP traffic and lower-priority fan-outat creation time, giving each lane its own trigger table policy,capacity budget, topic, and cancellation semantics. Lookahead couldadapt to per-domain fire-time lag and burst forecasts instead of usingone global window.</p><h3 id="shared-topic-backpressure">Shared-topic backpressure</h3><p>The next isolation failure came after onboarding the audience-SOPscenario. The SDK gave each business its own consumer group, so Pulsarshowed exactly which subscription was falling behind. But all commandsstill entered one shared topic; its backlog produced producerbackpressure and made a local problem platform-wide.</p><p>We split the topic per business domain. A slow consumer could thendelay only its own domain while other businesses kept flowing and couldbe throttled or scaled independently. The diagrams show the resultingtopic boundary. Topic-per-business-domain and per-business concurrencyquotas would be initial SDK contracts in a new implementation.</p><h3 id="a-cancellation-design-we-removed">A cancellation design weremoved</h3><p>Early admission created another contract problem: Pulsar did notsupport canceling a message after it was published with a futuredeliver-at time. We deployed a Redis-based design to production: publishset a state, consumption updated it, and the SDK filtered canceledcommands while locks attempted consistency.</p><p>Production did not justify that complexity. Redis load rose, thelock-and-state path became hard to reason about, and debugging spannedTiDB, Pulsar, and Redis. Actual cancel demand was very low anddownstream async handlers were already idempotent, so we removed theRedis layer and treated <strong>admission as the cancellationboundary</strong>.</p><h2 id="results">Results</h2><ul><li>Peak publish rate above 200K execution commands per second duringon-the-hour workload peaks.</li><li>Coverage of more than 20 business scenarios over the system'sproduction lifetime.</li><li>Full replacement of both the batch center and xxl-job, with agradual per-business cutover and no big-bang migration.</li></ul><p>The 200K+ figure came from the Pulsar Manager publish-rate panel:messages per second on the execution-command topics. For the SOPworkload that drove the peak, one sharding item produced one command;larger fan-outs produced proportionally more.</p><p>We did not retain fire-time lag percentiles, so the publish rateestablishes throughput without establishing end-to-end latency. I wouldinstrument fire-time lag—the difference between a trigger's scheduledtime and its actual delivery—as an SLI from day one, with alerts onplatform-wide and per-domain percentiles.</p><h2 id="operating-costs">Operating costs</h2><p>Every state transition added write amplification on TiDB. Partitioncount became a capacity-planning burden, since changing it later is notfree. At-least-once delivery required idempotent executors. The30-minute lookahead absorbed hot-minute bursts but also widened theperiod during which an admitted trigger could no longer be canceled inthe queue.</p><p>I would keep durable triggers in TiDB and admitted commands inPulsar, with per-domain command topics. The next change is operational:persist ownership for <code>acquired</code> rows and expose per-domainfire-time lag percentiles. That is a smaller correction than movinglong-lived task state into the queue.</p><hr /><p><em>Previous in this series: <ahref="/posts/engineering-case-study/debugging-a-sporadic-go-oom-in-a-recursive-json-encode-path/">Debugginga Sporadic Go OOM in a Recursive JSON Encode Path</a></em>.</p><p><em>Next in this series: <ahref="/posts/engineering-case-study/scaling-four-engineers-to-eighteen-go-services-with-generated-contracts/">ScalingFour Engineers to Eighteen Go Services with GeneratedContracts</a></em>.</p>]]>
    </content>
    <id>https://edwardshen125.github.io/posts/engineering-case-study/building-a-production-go-scheduler-for-200k-delayed-jobs-per-second/</id>
    <link href="https://edwardshen125.github.io/posts/engineering-case-study/building-a-production-go-scheduler-for-200k-delayed-jobs-per-second/"/>
    <published>2025-01-01T06:00:00.000Z</published>
    <summary>How a Go scheduling center used TiDB, etcd, and Pulsar to publish more than 200K delayed jobs per second, and how production incidents reshaped its isolation model.</summary>
    <title>Building a Production Go Scheduler for 200K+ Delayed Jobs per Second</title>
    <updated>2026-10-21T08:09:54.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Edward Shen</name>
    </author>
    <category term="Engineering Case Study" scheme="https://edwardshen125.github.io/categories/Engineering-Case-Study/"/>
    <category term="go" scheme="https://edwardshen125.github.io/tags/go/"/>
    <category term="event-driven" scheme="https://edwardshen125.github.io/tags/event-driven/"/>
    <category term="json" scheme="https://edwardshen125.github.io/tags/json/"/>
    <category term="oom" scheme="https://edwardshen125.github.io/tags/oom/"/>
    <category term="production-debugging" scheme="https://edwardshen125.github.io/tags/production-debugging/"/>
    <content>
      <![CDATA[<p>The alert came from Grafana. A Go service on the SOP event chainreached its 8 GB Pod memory limit and restarted; process memory wentfrom flat to the limit in under a second, with no correlation to trafficor time of day.</p><p>The service ran across twelve pods, supporting SOP workflows for morethan 30,000 enterprise customers. It consumed close to ten millionevents per day and supported daily outreach to hundreds of thousands ofend users. The alerts kept coming for over a week: on most days a burst— one pod lost, then another within minutes as if the failure movedbetween them — and three or four on the worst days, until each burstexhausted itself and went quiet. Nothing in the standard instrumentscaptured why.</p><span id="more"></span><img data-src="/posts/engineering-case-study/debugging-a-sporadic-go-oom-in-a-recursive-json-encode-path/debugging-recursive-json-encode-oom.png" class="" title="Incident build: SOP event chain, assignment engine to publish path, jsoniter codec compiler, embedded-cycle recursion and the 8 GB alert"><p>Events moved through this service in both directions: it consumedupstream events through the SDK's envelope decoder, and published itsown events back through the same SDK's <code>json</code> package. Eachevent type had its own code on the wire and its own handler.</p><p>This was the second memory incident on that path in a short window,and the two looked nothing alike. The first was a slow climb: memoryrose with business peaks and eventually reached the limit. I had alreadyfixed that one: on March 7 I moved the JSON package behind threeinternal SDK paths — the event SDK's shared <code>json</code>, a cache,and an executor SDK — from <code>encoding/json</code> to <ahref="https://github.com/json-iterator/go">jsoniter</a>. At our consumerate, stdlib's unmarshal allocated heavily per event; jsoniter reusedits read buffers, memory fell back and stayed down. Because it was onepackage-level swap, decode and publish both went through jsoniter fromthen on.</p><p>Days later, a feature iteration landed in this service. It consumed<span class="exturl" data-url="aHR0cHM6Ly9kZXZlbG9wZXIud29yay53ZWl4aW4ucXEuY29tL2RvY3VtZW50Lw==">WeCom<i class="fa fa-external-link-alt"></i></span>'sowner-change event — an employee departs, WeCom hands their customersover — and this service's assignment engine distributed those customersamong the remaining staff by rule, publishing an assignment event foreach split. Departures are rare — a handful per tenant per month — sothese events were a small fraction of the millions of daily events. Thefeature had shipped once before with a flat event type, verified end toend by the automated suite on a test tenant.</p><p>The second iteration added chain support: a departing employee'scustomers could exceed one receiver's capacity, so the assignment enginere-dispatched the remainder to the next receiver, and that dispatch wasitself an assignment event. Each re-dispatch carried the assignment itcontinued, so the engine could audit how one departure was split anddeduplicate overlapping splits. Every step of that chain was the sameevent type, so the field carrying the previous assignment pointed at thetype itself — an anonymous embedded pointer, the event layer's standingconvention. Every event in the chain was published through the SDK's<code>json</code> package, the one I had just switched to jsoniter. Thealerts that followed were this article's subject: a growth curve pointsat accumulation, a spike at a single allocation path gone wrong, and theinstruments that watched the first had nothing to say about thesecond.</p><p>The migration left jsoniter behind every JSON touchpoint in theservice: envelope decode, cache, an executor SDK, and the publish path.The first three were dependency pins, swappable one at a time. Thepublish path was event-layer business code and shipped only inside theevent SDK's builds. That distinction decided how the suspicion could betested, and later how the failure was isolated.</p><h2 id="what-the-failing-instrumentation-could-not-see">What the failinginstrumentation could not see</h2><p>Four standard instruments were considered:</p><ul><li><strong>Logs</strong>: the triggering events were ordinary, and theprocess died before the consumer could record its state.</li><li><strong>Scheduled pprof</strong> sampled too infrequently to catch asub-second spike.</li><li><strong>Threshold-triggered pprof</strong> threw false positivesfrom normal allocation and could still miss a jump between polls.</li><li><strong>Core dumps</strong>: the memory-limit kill sent<code>SIGKILL</code> before a core could be written.</li></ul><p>All four needed the process to survive long enough to leave evidence;this one did not. After a week it had no time signature — what it mostplausibly indexed was the set of recent changes on the JSON path, whichonly the build could step through one at a time.</p><h2 id="the-codec-suspicion">The codec suspicion</h2><p>The JSON change I knew about was my own March 7 migration, sojsoniter was the first suspect. On March 11 I swapped it for <ahref="https://github.com/bytedance/sonic">sonic</a> on the cache path —a dependency swap. The publish path, business code inside the SDKbuilds, was beyond that kind of swap and stayed on jsoniter. The OOMrecurred within a day; I rolled sonic back the next morning.</p><p>That experiment ruled out the cache path, not jsoniter as a whole.Jsoniter still ran on the envelope, the executor SDK, and the publishpath. No dependency swap could reach those remaining paths; stepping theSDK builds could. I stopped swapping libraries and started bisectingbuilds, treating the recent JSON changes as a single bundled delta.</p><h2 id="the-bisection">The bisection</h2><p>On March 12 I rolled back to the last known stable build and bisectedthe incident delta — one staged deploy and observation window per step,eight days across eight rollbacks. The rollback gave a baseline, not ananswer — pods still died daily, so a quiet window proved nothing. Thebuilds stepped the internal event SDK between versions that carried thenew concrete event type and versions that did not; jsoniter itself nevermoved. The SDK version was the deployable handle for the bundled change:subscription-layer code and its dependency pins shipped together, sostepping it moved the new event type in and out of the process.</p><p>The bisection isolated the build delta that introduced the concreteevent type. Tracing it through the service: the engine built each chainevent and published it through the SDK's <code>json</code> package,where jsoniter lazily compiled an encoder for the type. The reduced<code>TransferEvent</code> case reproduced the resulting unboundedtraversal locally.</p><h2 id="the-mechanism-reconstructed">The mechanism, reconstructed</h2><p>The production type graph is reconstructed as containing theanonymous embedded-pointer cycle; the business shape below is likewise areconstruction of the iteration's semantics, not a transcript of itsfields.</p><p>jsoniter <ahref="https://github.com/json-iterator/go/blob/v1.1.12/reflect.go#L62-L72">compilesa codec on first use</a> — encoder and decoder share the same type walk— before the first byte is read or written. Named recursive fields arebounded by jsoniter's <ahref="https://github.com/json-iterator/go/blob/v1.1.12/reflect.go#L135-L145">placeholdercodec</a>. An anonymous embedded pointer takes a different path: <ahref="https://github.com/json-iterator/go/blob/v1.1.12/reflect_extension.go#L348-L373"><code>describeStruct</code>recursively expands the embedded struct</a> without a visited-typeguard. The <ahref="https://github.com/json-iterator/go/blob/v1.1.12/iter.go#L331-L338">decodedepth limit</a> is downstream and cannot protect a process that dieswhile compiling the codec.</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// jsoniter v1.1.12, reflect_extension.go:348-373, abridged</span></span><br><span class="line"><span class="keyword">if</span> field.Anonymous() &amp;&amp; (tag == <span class="string">&quot;&quot;</span> || tagParts[<span class="number">0</span>] == <span class="string">&quot;&quot;</span>) &#123;</span><br><span class="line">    <span class="comment">// ...</span></span><br><span class="line">    structDescriptor := describeStruct(ctx, ptrType.Elem())</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> TransferEvent <span class="keyword">struct</span> &#123;</span><br><span class="line">    *TransferEvent <span class="comment">// the assignment this event continues</span></span><br><span class="line">    CompanyID <span class="type">string</span> <span class="string">`json:&quot;companyId&quot;`</span></span><br><span class="line">    From      <span class="type">string</span> <span class="string">`json:&quot;from&quot;`</span></span><br><span class="line">    To        <span class="type">string</span> <span class="string">`json:&quot;to&quot;`</span></span><br><span class="line">    Reason    <span class="type">string</span> <span class="string">`json:&quot;reason&quot;`</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// jsoniter.Marshal(TransferEvent&#123;...&#125;)</span></span><br><span class="line"><span class="comment">// → fatal error: stack overflow, before the first byte is written</span></span><br></pre></td></tr></table></figure><h2 id="why-production-became-an-oom">Why production became an OOM</h2><p>The incident ran on Go 1.18, and the reduced case reproduces therewith jsoniter <code>v1.1.12</code> — the version the service had pinned.The same case reproduced in every release from <code>v1.1.5</code>through <code>v1.1.12</code>, while the standard library and a namedrecursive pointer field remained safe.</p><p>Production ran multiple goroutines inside an 8 GB Pod, and jsonitercompiled codecs lazily — concurrent first-use misses could place severalgoroutines in the same recursive path, so process memory grew inparallel and the Pod limit could arrive before any single goroutine hitits own stack limit. The harness below demonstrates that arithmetic.Most Pods never received a rare assignment event at all. Because thespike ended too quickly for a useful profile, the exact RSS contributionfrom goroutine stacks, heap objects, and runtime allocations remainsunknown.</p><p>The publish call never returned: the assignment event the engine wasemitting never reached the broker, and the owner-change message theservice had consumed to trigger it was never acknowledged either. Whenthe Pod was OOM-killed, its consumer connection closed with that messageunacknowledged, and the shared subscription redelivered it to anotherPod, where the same chain repeated the failure. This wasconsumer-disconnect redelivery, not an application-issued nack — the OOMnever advanced any retry counter. The burst ended only when the messageexhausted its redelivery budget and landed in the dead-letter topic:each departing employee's event burned through a handful of pods, thenwent quiet. We only found that out during the retrospective — nobody hadlooked at the dead-letter topic during the week of debugging, though itheld the failing messages, their retry trails, and the event code thatwould have pointed at the trigger from day one.</p><p>The harness below reproduces that loop locally as a single copy-pasteprogram, scaled down 16× (64 MB stack walls instead of Go's ~1 GBdefault) and with the pod limit simulated by an RSS threshold. Eachchild process is one "pod": eight goroutines hit the same first-usecompile, and the parent replays the unacknowledged message every timethe child dies:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Command oom-harness reproduces the incident&#x27;s cross-pod cycle in miniature.</span></span><br><span class="line"><span class="comment">// Each child process is one &quot;pod&quot;: N goroutines hit the same first-use</span></span><br><span class="line"><span class="comment">// jsoniter compile of a self-embedding type. The parent is the &quot;cluster&quot;:</span></span><br><span class="line"><span class="comment">// on every death it replays the unacknowledged message, as the shared</span></span><br><span class="line"><span class="comment">// subscription did after each OOM-killed pod disconnected.</span></span><br><span class="line"><span class="comment">//</span></span><br><span class="line"><span class="comment">//go run .          # runs three redelivery rounds, prints per-pod peak RSS</span></span><br><span class="line"><span class="comment">//</span></span><br><span class="line"><span class="comment">// Safety: the child caps each goroutine&#x27;s stack at 64 MB (16x below the</span></span><br><span class="line"><span class="comment">// runtime default), the parent fixes three rounds, and each pod dies in</span></span><br><span class="line"><span class="comment">// ~100ms — the whole run stays under a second and under 800 MB.</span></span><br><span class="line"><span class="keyword">package</span> main</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line"><span class="string">&quot;fmt&quot;</span></span><br><span class="line"><span class="string">&quot;os&quot;</span></span><br><span class="line"><span class="string">&quot;os/exec&quot;</span></span><br><span class="line"><span class="string">&quot;runtime/debug&quot;</span></span><br><span class="line"><span class="string">&quot;sync&quot;</span></span><br><span class="line"><span class="string">&quot;syscall&quot;</span></span><br><span class="line"><span class="string">&quot;time&quot;</span></span><br><span class="line"></span><br><span class="line">jsoniter <span class="string">&quot;github.com/json-iterator/go&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// Event is the incident&#x27;s type graph, reduced: an anonymous embedded</span></span><br><span class="line"><span class="comment">// self-pointer. Compiling a codec for it recurses without a visited set.</span></span><br><span class="line"><span class="keyword">type</span> Event <span class="keyword">struct</span> &#123;</span><br><span class="line">*Event</span><br><span class="line">ID <span class="type">string</span> <span class="string">`json:&quot;id&quot;`</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">workers    = <span class="number">8</span>   <span class="comment">// concurrent first-use compiles per pod</span></span><br><span class="line">stackMB    = <span class="number">64</span>  <span class="comment">// per-goroutine stack ceiling (production: ~1 GB)</span></span><br><span class="line">podLimitMB = <span class="number">300</span> <span class="comment">// simulated pod memory limit (production: 8 GB)</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// child is one &quot;pod&quot;: workers goroutines, one poison publish each.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">child</span><span class="params">()</span></span> &#123;</span><br><span class="line">debug.SetMaxStack(stackMB &lt;&lt; <span class="number">20</span>)</span><br><span class="line"><span class="keyword">var</span> wg sync.WaitGroup</span><br><span class="line"><span class="keyword">for</span> i := <span class="number">0</span>; i &lt; workers; i++ &#123;</span><br><span class="line">wg.Add(<span class="number">1</span>)</span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line"><span class="keyword">defer</span> wg.Done()</span><br><span class="line">_, _ = jsoniter.Marshal(Event&#123;ID: <span class="string">&quot;assign-1&quot;</span>&#125;) <span class="comment">// the publish that never returns</span></span><br><span class="line">&#125;()</span><br><span class="line">&#125;</span><br><span class="line">wg.Wait() <span class="comment">// unreachable: the process dies inside the first-use compile</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// peakRSS reads the finished child&#x27;s peak resident set from Rusage.</span></span><br><span class="line"><span class="comment">// Linux reports ru_maxrss in KB, macOS in bytes.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">peakRSS</span><span class="params">(cmd *exec.Cmd)</span></span> <span class="type">int</span> &#123;</span><br><span class="line"><span class="keyword">if</span> cmd.ProcessState == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="number">0</span></span><br><span class="line">&#125;</span><br><span class="line">ru, ok := cmd.ProcessState.SysUsage().(*syscall.Rusage)</span><br><span class="line"><span class="keyword">if</span> !ok &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="number">0</span></span><br><span class="line">&#125;</span><br><span class="line">kb := ru.Maxrss</span><br><span class="line"><span class="keyword">if</span> kb &gt; <span class="number">1</span>&lt;&lt;<span class="number">20</span> &#123; <span class="comment">// bytes -&gt; KB</span></span><br><span class="line">kb /= <span class="number">1024</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> <span class="type">int</span>(kb) / <span class="number">1024</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">main</span><span class="params">()</span></span> &#123;</span><br><span class="line"><span class="keyword">if</span> <span class="built_in">len</span>(os.Args) &gt; <span class="number">1</span> &#123; <span class="comment">// child mode: invoked by the parent below</span></span><br><span class="line">child()</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">for</span> redelivery := <span class="number">1</span>; redelivery &lt;= <span class="number">3</span>; redelivery++ &#123;</span><br><span class="line">cmd := exec.Command(os.Args[<span class="number">0</span>], <span class="string">&quot;pod&quot;</span>)</span><br><span class="line">start := time.Now()</span><br><span class="line">_ = cmd.Run()</span><br><span class="line">peak := peakRSS(cmd)</span><br><span class="line">fmt.Printf(<span class="string">&quot;redelivery %d: pod died in %v, peak RSS %d MB&quot;</span>, redelivery,</span><br><span class="line">time.Since(start).Round(time.Millisecond), peak)</span><br><span class="line"><span class="keyword">if</span> peak &gt;= podLimitMB &#123;</span><br><span class="line">fmt.Printf(<span class="string">&quot; — over the %d MB pod limit: an OOMKill before any single %d MB stack wall\n&quot;</span>,</span><br><span class="line">podLimitMB, stackMB)</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">fmt.Println()</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">redelivery 1: pod died in 100ms, peak RSS 773 MB — over the 300 MB pod limit: an OOMKill before any single 64 MB stack wall</span><br><span class="line">redelivery 2: pod died in 85ms, peak RSS 760 MB — over the 300 MB pod limit: an OOMKill before any single 64 MB stack wall</span><br><span class="line">redelivery 3: pod died in 86ms, peak RSS 773 MB — over the 300 MB pod limit: an OOMKill before any single 64 MB stack wall</span><br></pre></td></tr></table></figure><p>The peak scales with concurrency — eight goroutines × one stack each— which is the arithmetic behind the production OOM: process memory grewin parallel while the stack limit only ever guarded one goroutine at atime. On this laptop the child still dies to the stack wall — no cgroupexists to kill it first. Inside a real container limit, the memory wallarrives first, the process dies without a word, and the parent'sredelivery loop replays the cross-pod cycle the incident showed.</p><p>Two controls in the harness's style isolate the trigger — same call,same shape, one property changed each time:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Named recursive field: jsoniter&#x27;s placeholder codec terminates compilation.</span></span><br><span class="line"><span class="keyword">type</span> NamedEvent <span class="keyword">struct</span> &#123;</span><br><span class="line">Previous *NamedEvent <span class="string">`json:&quot;previous,omitempty&quot;`</span></span><br><span class="line">ID       <span class="type">string</span>      <span class="string">`json:&quot;id&quot;`</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">_, _ = jsoniter.Marshal(NamedEvent&#123;ID: <span class="string">&quot;assign-1&quot;</span>&#125;) <span class="comment">// completes</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// Same anonymous shape with the standard library: field resolution terminates.</span></span><br><span class="line">_, _ = json.Marshal(Event&#123;ID: <span class="string">&quot;assign-1&quot;</span>&#125;) <span class="comment">// completes</span></span><br></pre></td></tr></table></figure><p>The standard library uses a <ahref="https://github.com/golang/go/blob/go1.18/src/encoding/json/encode.go#L1235-L1239">visitedmap when resolving fields</a> — the very function jsoniter's branchlacks. The same domain shape had been serialized under<code>encoding/json</code> since the feature first shipped; onlyjsoniter's compilation entered the failing traversal.</p><p>Sonic avoids the failing traversal at the field-resolution step. In<code>v1.14.0</code>, its resolver <ahref="https://github.com/bytedance/sonic/blob/v1.14.0/internal/resolver/stubs_latest.go#L49-L51">delegatesto <code>encoding/json.typeFields</code></a>; in <code>v1.14.1</code>and <code>v1.15.4</code>, it performs that traversal locally <ahref="https://github.com/bytedance/sonic/blob/v1.14.1/internal/resolver/fields.go#L49-L75">witha visited-type map</a>. Both approaches stop revisiting the anonymouslyembedded type. Separately, sonic's JIT and optimized codec compilers <ahref="https://github.com/bytedance/sonic/blob/v1.14.0/internal/decoder/jitdec/compiler.go#L595-L609">tracktypes already being compiled</a> and handle recursive fields withoutrepeatedly expanding their work. For this embedded-cycle case, thefield-resolution guard is the relevant protection. These areretrospective checks (Go <code>1.24.9</code>, macOS, JIT and optimizedpaths both ways); they verify the reduced case, not the production sonicversion or runtime.</p><h2 id="why-the-automated-suite-stayed-green">Why the automated suitestayed green</h2><p>The suite that had verified the first release stayed green for amechanical reason. It exercised the real chain on a test tenant —simulate a departure, let the engine publish, consume — but by the timethis iteration ran, that tenant's engine had been switched off for aparallel feature's testing, and nothing flagged the gap — a green runlooked exactly like an empty one. With the engine off, no assignmentevent was ever built; nothing published; and jsoniter compiles a codeconly on first use — zero publishes meant zero compilations. The greenwas real but empty: no test ever reached this change. Unit tests couldnot either — they call the engine with struct literals and neverserialize an event.</p><h2 id="the-fix">The fix</h2><p>With the cause isolated, the fix followed directly: the publishertook its own sonic reference instead of the SDK's shared<code>json</code>, so the envelope and every other consumer of thatvariable kept jsoniter. I validated the move first against the reducedreproduction, which sonic encodes without entering the failingtraversal. The jsoniter migration kept the win it had already delivered:the envelope and cache paths stayed on it, and the slow-climb incidentdid not come back. Fixing the type instead (adding a field name or tagto break the anonymous embedding) would also have closed this failure,as the named-field control demonstrates; the migration was chosenbecause it left the shared event contract untouched while covering everyevent type at once.</p><p>The production OOM stopped after the migration and never recurredthrough the months that followed, while the service kept running until Ileft the company. These results make the publish-path encoder thestrongest operational explanation for the OOM. They do not identify theexact production RSS split between goroutine stacks, heap, and runtimeallocations; the spike ended before a useful profile could be captured.The type graph itself remained unchanged; the source comparison in theprevious section explains why sonic handles the embedded cycle.</p><h2 id="lessons-from-the-investigation">Lessons from theinvestigation</h2><ul><li>The shape of the failure decided the tool. A sub-second spike withno time signature ruled out accumulation, pointed at a single allocationpath, and made every runtime instrument useless — the process diedbefore leaving evidence. I lost days to codec swaps and quiet windowsbefore accepting that; build bisection was the expensive but onlydecisive step — eight rollbacks to one delta. For a fast-fatal failure,read the shape first and step builds early.</li><li>I swapped one package-level <code>json</code> variable to fix slowmemory growth and silently moved the publish path to jsoniter with it.Now, before any dependency swap, I list every caller of what I'mtouching.</li><li>The dead-letter topic held the failing messages and their retrytrails for the whole week we chased the OOM; we found it in theretrospective. Now a consumer incident starts with DLQ depth and retrycounters, before any profiling.</li></ul><hr /><p><em>Previous in this series: <ahref="/posts/engineering-case-study/running-a-million-dau-casual-game-on-go-rooms-as-the-concurrency-boundary/">Runninga Million-DAU Casual Game on Go: Rooms as the ConcurrencyBoundary</a></em>.</p><p><em>Next in this series: <ahref="/posts/engineering-case-study/building-a-production-go-scheduler-for-200k-delayed-jobs-per-second/">Buildinga Production Go Scheduler for 200K+ Delayed Jobs perSecond</a></em>.</p>]]>
    </content>
    <id>https://edwardshen125.github.io/posts/engineering-case-study/debugging-a-sporadic-go-oom-in-a-recursive-json-encode-path/</id>
    <link href="https://edwardshen125.github.io/posts/engineering-case-study/debugging-a-sporadic-go-oom-in-a-recursive-json-encode-path/"/>
    <published>2024-10-01T02:00:00.000Z</published>
    <summary>A Go service hit its 8 GB Pod limit when a recursive event type entered jsoniter's lazy encoder compilation path. Bisection isolated it; moving only that publish path to sonic stopped the incident.</summary>
    <title>Debugging a Sporadic Go OOM in a Recursive JSON Encode Path</title>
    <updated>2026-10-26T08:14:55.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Edward Shen</name>
    </author>
    <category term="Engineering Case Study" scheme="https://edwardshen125.github.io/categories/Engineering-Case-Study/"/>
    <category term="distributed-systems" scheme="https://edwardshen125.github.io/tags/distributed-systems/"/>
    <category term="go" scheme="https://edwardshen125.github.io/tags/go/"/>
    <category term="kafka" scheme="https://edwardshen125.github.io/tags/kafka/"/>
    <category term="event-driven" scheme="https://edwardshen125.github.io/tags/event-driven/"/>
    <category term="wecom" scheme="https://edwardshen125.github.io/tags/wecom/"/>
    <category term="data-consistency" scheme="https://edwardshen125.github.io/tags/data-consistency/"/>
    <content>
      <![CDATA[<p>The escalation arrived as a refund threat. A customer had run acampaign, and thousands of people scanned its QR codes that dayexpecting to reach the sales team. One salesperson on the code hadalready hit his daily quota — the system showed him offline forassignment — and every new scan still connected to him. The rotation tothe next salesperson never fired. The campaign closed far below target,the customer demanded a refund, then churned: a six-figure annualcontract. The same mis-assignment had been producing quieter supporttickets for months. The logic was correct; the member list it computedfrom was not.</p><span id="more"></span><p>A live QR code is printed once and keeps working while the peoplebehind it change. The customer scans in <ahref="https://www.wechat.com/">WeChat</a>, <ahref="https://www.wecom.com/">WeCom</a> routes to whoever the codecurrently lists, and we maintain that list. At 30,000 tenants with 10 to20 staff on a QR code, most of them stayed idle until a campaign drovetraffic to them for days or weeks.</p><p>I owned this system — took it over as it stood. The tickets fell intotwo classes with one cause: staff shown offline still receiving scans,staff over quota still receiving scans.</p><h2 id="the-routing-was-never-ours">The routing was never ours</h2><p>WeCom owns the part that connects people. Tencent's enterprisemessenger matches each scan to a <ahref="https://developer.work.weixin.qq.com/document/path/92228">contact-wayconfiguration</a> on its side and opens the chat with whoever thatconfig lists. Our platform never sees the scan that opens the chat. Soassignment, for us, meant one thing: maintaining that config. Decidingwhich staff IDs belong in it, and pushing every change through WeCom'sAPI.</p><p>Two stores had to agree. Our staff state — online, on duty, underquota, per tenant — lived in <ahref="https://www.mongodb.com/">MongoDB</a>. WeCom's config decidedwhere customers actually went. When the two disagreed, nothing raised anerror; production simply kept routing by the stale side, and scanslanded on someone we had marked offline an hour earlier. Repairing itwas not free either: the fix itself was a rate-limited API call with itsown failure modes.</p><p>The QR codes came in two families. Native codes handed memberselection to WeCom's random assignment, and our only job was whoappeared in the config. Sequenced and quota codes ran on our own landingpage, so we decided the order and the per-person volume as well. Theoffline and quota checks mattered most on these codes: a rotation thatnever happens is a list that never got updated.</p><h2 id="two-years-of-features-six-writers">Two years of features, sixwriters</h2><p>Before I took over, feature work had put this state together piece bypiece:</p><ul><li>State split across many collections. Online/offline flags (an adminendpoint, work-schedule timers), the per-staff-per-code ceiling records,the "already removed today" marker, and the counts that decided who wasfull — all in MongoDB, incremented by <code>$inc</code> on each scancallback. Four switches per code — offline check, add limit, dailylimit, round limit — plus a company-wide daily total decided how thosenumbers were applied.</li><li>Many paths wrote it. The scan callback, the admin offline endpoint,the quota counters, work-hours jobs, resignation handlers, org-changeevents. Each pushed its own update toward WeCom when it decided the listshould change: inline, with its own retry, at its own moment.</li><li>One slice already had a queue. Sequenced and quota codes used a <ahref="https://redis.io/docs/latest/develop/data-types/sorted-sets/">RedisZSET</a> with a two-level cursor, built by a teammate in 2021. Staffstate-change events went through <ahref="https://kafka.apache.org/">Kafka</a> to a consumer that adjustedthe set accordingly — full recompute on edits and shift changes, add areturning member, delete one who left or hit quota. Scans read the setthrough <ahref="https://redis.io/docs/latest/develop/interact/programmability/eval-intro/">Lua</a>.But every one of those paths went through the same consumer,asynchronously, and a full rebuild meant deleting and recreating thewhole key. Between "staff hit quota" and "set rebuilt without him",scans kept drawing the old membership.</li></ul><p>Each path was sensible on its own; the bug was in none of them.<strong>The same state had many concurrent writers, and each writerpushed a list computed from a partial view.</strong></p><p>Quiet days hid it: with little concurrent traffic, the writers rarelyinterleaved badly enough to lose a change. Campaign day removed thatsafety margin — scan volume and status changes peaked in the sameminutes, exactly when the gap between "state says stop" and "WeComconfig says go" was widest.</p><p>Both failure modes had been reaching support for months, as raretickets. Each was reproducible only under load, so each got chased as apossible logic bug and closed when no code defect turned up.</p><h2 id="three-fixes-that-should-have-worked">Three fixes that shouldhave worked</h2><p>Once the defect was named — state and routing config drifting apart —three fixes suggested themselves. Each one fails on a structuralfact.</p><p><strong>Check at scan time.</strong> Intercept the scan, read staffstate, route around anyone offline or over quota. For native codes thisis impossible in principle — the scan never touches our platform.Sequenced and quota codes do pass through our landing page, so the checkwas possible there — but it failed twice over. It put a database read onevery scan of the hottest path we had. Worse, it read the same state theconcurrent writers were updating, so the check itself could see a staleview and pass the person it was meant to skip.</p><p><strong>Sweep and repair.</strong> A periodic job that walks every QRcode, compares our state against WeCom's config, and pushes a repairwhere they disagree. As a safety net this is the right shape, and itstayed on the list for later. As the primary fix it fails on arithmetic.<span class="exturl" data-url="aHR0cHM6Ly9kZXZlbG9wZXIud29yay53ZWl4aW4ucXEuY29tL2RvY3VtZW50L3BhdGgvOTAzMTI=">WeComdocuments<i class="fa fa-external-link-alt"></i></span> a ceiling of 10,000 calls a minute and 150,000 an hour perenterprise for a single API, and the 6M codes sit concentrated withheavy users: working through one heavy tenant's codes at that ceilingtakes tens of hours, in a window shared with the live pushes that keeparriving. This failure appears in minutes and has to be corrected inseconds; a sweep of tens of hours can only fix the slow kind ofdrift.</p><p><strong>Better retries on every writer.</strong> Make every writepath idempotent, add exponential backoff, let each writer retry until itlands. This is the status quo with more machinery. A retry makes onewrite more likely to succeed; it says nothing about two writers pushinglists computed from different snapshots — last write wins, and thewinner's list is already stale. The defect was order, and retries do notorder anything.</p><p>All three patched where the symptom surfaced; the alternative was toremove the multiple writers.</p><h2 id="one-writer-recomputing">One writer, recomputing</h2><img data-src="/posts/engineering-case-study/one-queue-for-every-state-change-fixing-silent-over-assignment-in-a-live-qr-code-system/architecture.png" class="" title="One queue serializes every membership-changing event: sources feed a Kafka topic keyed by QR code ID; a serial consumer recomputes members, pushes WeCom" alt="s contact-way config first, then writes back to MongoDB and rebuilds the Redis assignment set. The scan path never passes through our platform."><p>I put one Kafka queue between "state changed" and "WeCom configchanged". Every event that can alter a QR code's membership goes to onetopic, keyed by the QR code's own ID: an addition, a status flip, aschedule transition, a resignation, an org change, a config edit. Aconsumer group works through them. For each affected code it recomputesthe eligible members from current state, using the same four switcheseach old writer had checked on its own. It pushes that update to WeCom,and only then writes the result back to our store. WeCom is whererouting happens, so it gets the correct list first. And the store iswhat the dedupe check reads — the record of the last list successfullypushed — so it must only ever reflect completed pushes. Write the storefirst and the two writes can be split by a failed call: the record thenclaims a list WeCom never received, the redelivered event recomputes thesame list, the dedupe check matches it against the lying record, skipsthe call, and the divergence is permanent. The store is written onlyafter the push succeeds, so the dedupe record is never ahead of whatWeCom actually holds.</p><p>The state has to move before the event does. The counter that decideswho is full is a MongoDB document per staff member and code, incrementedby <code>$inc</code>. The consumer handling WeCom's customer-addedcallback increments it while it handles the addition, before itpublishes the refresh. By the time the refresh runs, the count iscurrent, and the event carries no counter value — the consumer reads thecount itself. Every other trigger follows the same rule: write the flagor the counter where the business event happens, then emit the refresh.Concurrency did not disappear — each handler still owns its own fact,and different facts never conflict. What the queue removed is the thingthe old writers raced on: concurrent composition of the same derivedlist. The queue carries a reason to recompute, not the state toapply.</p><p>Sequenced and quota codes still rotate on Redis, through the sameZSET and two-level cursor, but the two families now share one writer.For either family, the refresh consumer recomputes from the counts andflags as they stand and writes both routing targets — the config WeComreads and the set our landing page draws from — out of one computation,in one serial order. Before, each target had its own writer at its ownmoment — two writers for one code's membership, which was the race.</p><p>Serialization comes from the key. Events are keyed by the QR code'sID, and the consumer routes each key to a fixed goroutine, so one code'sevents apply one at a time while codes proceed in parallel. What needsordering is a single code's member list, and nothing else shares it. Theshared eventbus client we extended supplies that per-key mode, alongwith the retry path below.</p><p>The quota loop closes on the same queue, and it is where the loadcomes from. A scan callback increments a staff member's counter past theceiling. The handler marks the ceiling document with an idempotent"already removed today" flag, notifies the admin, looks up every codethat staff member sits on, and emits one membership event per QR code.Dozens of QR codes, sometimes more than a hundred. One removal becomesthat many WeCom pushes, and campaign day concentrates both the removalsand the QR codes.</p><h2 id="what-the-constraints-forced">What the constraints forced</h2><p>Rate pressure and business freshness are two problems. The designseparates their cadence, not their budget — both lanes still draw on onerate window.</p><p>The lane a code runs in is a business decision: the tenant's tier,how fresh that code's membership has to be, an operator's switch. I keptthat decision a pure function evaluated per event — the code's creationand update age, a tenant whitelist — with no lane state stored anywhere.Every event starts on the fast topic; when the function says slow, theconsumer republishes it to the slow topic without a call. Producersnever judge lanes; the decision lives in one place, the serial consumer.A stateless call survives rebalances by construction, and a rule changetakes effect on the next event.</p><p>The cost of statelessness is a transition window. When policy flips acode from slow to fast, one event may still be queued on the slow topicwhile the next runs fast — briefly two in-flight workers for one code. Iaccepted that: both lanes run the same single-writer computation, theconsumer takes a per-code lock in Redis around each refresh, andwhichever push lands last was computed from the latest state. The nextbusiness event on that code recomputes from current state on the correctlane, so the divergence is transient by design — eventual consistency,bounded by how often the code's own traffic produces events.</p><p>The vendor's rate limit is handled optimistically, on the call path.Before calling WeCom, the handler makes two checks. The first is dedupe:compare the list just recomputed with the last list pushed for thatcode, and if they match there is nothing to send. That does not shrinkthe fan-out; ten QR codes with ten different lists are ten real pushes.It removes the no-ops — a retry, an edit that resolves to what isalready there.</p><p>The second check is the tenant's pre-limiter: a <ahref="https://en.wikipedia.org/wiki/Token_bucket">token bucket</a> inRedis, spent through a Lua script so every instance counts against onewindow. With a token, push. Without one, the handler does not wait anddoes not hold the message open while the window refills. It acks asnot-done and requeues the event onto a retry topic with a delay — thepause is explicit, not a busy wait — after which it re-enters under thesame key, behind whatever that code's stream processed, through the sametwo checks. No second writer, and no consumer goroutine parkedmid-message.</p><p>Slow is a topic of its own, not a branch inside a handler: the slowlane enforces its interval by sleeping between a code's pushes — adeliberate down-throttle, not a message held open — and its workers areseparate from the fast lane's, so a sleeping slow worker parks only thatcode's slow stream and never delays the same code's fast events. Theinterval, seconds by policy, is the business decision, not the limiter.Both lanes draw on the same tenant's rate window — ordering is per code,the vendor's quota is not — so a code on slow competes with its tenant'sfast work for the vendor's limit. It draws fewer attempts from thatshared window.</p><p>A stateless lane call also means a code can linger on slow after aburst — extra delay, not divergence, on codes whose membership is notchanging. I took that staleness over maintaining per-code lanestate.</p><img data-src="/posts/engineering-case-study/one-queue-for-every-state-change-fixing-silent-over-assignment-in-a-live-qr-code-system/lanes.png" class="" title="Two lanes, two cadences, one rate window: every event enters the fast topic keyed by QR code ID; the serial consumer" alt="s pure lane function — code age and tenant whitelist, no stored state — pushes now or republishes to the slow topic, where a separate worker set sleeps seconds between pushes. Both lanes converge on one push path: per-code Redis lock, dedupe, per-tenant token bucket. Without a token the event is acked as not-done onto a retry topic and re-enters the same key"><p>Failures do not hold the code's stream. A failed call is reported asnot-done and redelivered by the eventbus under the same key, instead ofbeing left unacknowledged while the consumer backs off. Holding theoffset would be the obvious choice and the wrong one even at codegranularity. A failing call in a retry storm pins its partition, and thecodes sharing that partition — a whole tenant's worth, under a busycampaign — wait behind it. Ack and redeliver keeps the blast radius atone code. WeCom's errors are specific enough to act on, too: a QR codedeleted on their side returns a distinct error, which the consumertreats as a cleanup trigger instead of a retry-forever case.</p><p>Requeueing gives up publish order; dropping gives up the messageafter a day. Neither matters: handlers recompute at processing time, soa late trigger reaches the same current truth, and a dropped event losesnothing that the next event would not recompute anyway. The design needsone writer per code. Arrival order was the part we could give up. Theresidual is a QR code no later event touches — staff went offline duringa multi-hour backlog, nothing followed, the stale list stays. CampaignQR codes are never idle that long, which is why the rule held inpractice: an assumption about traffic, not a guarantee about state.</p><h2 id="what-it-cost-and-what-it-bought">What it cost, and what itbought</h2><p>Migration ran as double-write through the second half of 2022 and cutover in early 2023. The new event chain went live while the oldsynchronous paths kept running; for weeks a diff job compared the memberlists the two paths pushed and alerted on any mismatch — it raised none.A config switch then retired the old paths. Tenant traffic did notnotice.</p><p>Freshness became bounded. Seconds on the fast lane, the slow lane'scadence, or the next redelivery after a throttled push, never theinstant of an inline write. After the new chain went live: p95 fromevent to WeCom's config under five seconds, measured from event publishto push completion in the metrics pipeline, and six consecutive monthswithout one ticket for either failure mode, against at least three amonth before.</p><p>WeCom's API became the explicit throughput ceiling. Under themulti-writer design nobody owned the rate limit; throttling showed up asscattered errors after the fact. One consumer made the ceiling visibleand manageable, with per-tenant rate limiting, redelivery on throttle,and robot alerts on throttle responses, and turned a recurring incidentsource into a scheduling parameter. The system held it: around 30,000tenants, around 6M live QR codes among heavy users, several hundredscans per second in short campaign spikes, peak-day volume under amillion.</p><p>One dependency more than before. Kafka availability now gatesmembership updates. The failure mode errs toward safety: if the queuestalls, updates are delayed rather than misapplied, and scans runagainst the last consistent list. The real cost is operational. A secondsystem to monitor, plus <ahref="https://en.wikipedia.org/wiki/Poison_message">poison messages</a>and lag, a class of bug MongoDB-only code never had.</p><p>The churned account came back. After running on the refactored systemfor a while, they re-subscribed. A customer who has lost technical trustdoes not return for a discount.</p><p>These are production numbers, not a load test, and the complaintrecord is the proof. I leave the incident account's campaign lossunquantified on purpose: it is the customer's data, not ours to publish.The churn and the re-subscription carry that part of the argument.</p><h2 id="what-i-would-keep-and-what-i-left-open">What I would keep, andwhat I left open</h2><p>I would keep the central decision: one serialized queue, keyed by theQR code, applying to WeCom first and our store second.</p><p>The production surprise was that serialization was the easy part;living with WeCom's rate limits was the long tail. If I designed itagain, I would budget the dependency's shape too: the rate window andthe drain rate should come from the fan-out arithmetic — how many liveQR codes a member sits on — not the event rate, which understates callsby that factor. Coalescing is half-built: a Redis marker drops a newrefresh event while one for the same code is still queued; the missinghalf is merging events at push time, since the push replaces the wholelist.</p><p>On <span class="exturl" data-url="aHR0cHM6Ly9wdWxzYXIuYXBhY2hlLm9yZy8=">Pulsar<i class="fa fa-external-link-alt"></i></span> I would trycollapsing the lanes into subscription semantics: one topic with aKey_Shared subscription keyed by the QR code ID. The broker routes eachkey to a single consumer, so the fast/slow split, the separate workersets, the per-code lock, and the hand-rolled retry topic all disappear —a demotion or a throttle becomes a negative ack with a redelivery delay,the same ack-as-not-done pattern we had built by hand on Kafka. Aredelivered message can come back out of order, which is the arrivalorder the design had already given up.</p><p>Reconciliation is the part I never closed. The rejected sweep had theneed right; its shape was the problem. The queue guarantees a freshread, not a correct outcome — a bug in the recompute gets applied justas consistently. But 6M QR codes at the documented rate limits is tensof hours of work to protect a handful of suspects. The backstop shouldbe a dirty set — codes whose events were dropped, whose pushes failedpermanently, or whose recomputes errored — swept at low frequency. Wehad no such layer; the evidence was customer complaints, weaker than asystem this size should run on.</p><p>One boundary is permanent: assignment lives in WeCom's config, andnothing inside our platform makes that system synchronous with us — thedesign only keeps the delay across it bounded and visible.</p><hr /><p><em>Previous in this series: <ahref="/posts/engineering-case-study/running-a-million-dau-casual-game-on-go-rooms-as-the-concurrency-boundary/">Runninga Million-DAU Casual Game on Go: Rooms as the ConcurrencyBoundary</a>.</em> <em>Next in this series: <ahref="/posts/engineering-case-study/debugging-a-sporadic-go-oom-in-a-recursive-json-encode-path/">Debugginga Sporadic Go OOM in a Recursive JSON Encode Path</a>.</em></p>]]>
    </content>
    <id>https://edwardshen125.github.io/posts/engineering-case-study/one-queue-for-every-state-change-fixing-silent-over-assignment-in-a-live-qr-code-system/</id>
    <link href="https://edwardshen125.github.io/posts/engineering-case-study/one-queue-for-every-state-change-fixing-silent-over-assignment-in-a-live-qr-code-system/"/>
    <published>2023-05-08T02:00:00.000Z</published>
    <summary>A live QR code system kept sending customers to offline or over-quota salespeople. One Kafka queue, keyed per QR code, serialized every update to WeCom's routing config.</summary>
    <title>One Queue for Every State Change: Fixing Silent Over-Assignment in a Live QR Code System</title>
    <updated>2026-10-26T08:14:55.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Edward Shen</name>
    </author>
    <category term="Engineering Case Study" scheme="https://edwardshen125.github.io/categories/Engineering-Case-Study/"/>
    <category term="go" scheme="https://edwardshen125.github.io/tags/go/"/>
    <category term="game-server" scheme="https://edwardshen125.github.io/tags/game-server/"/>
    <category term="ecs" scheme="https://edwardshen125.github.io/tags/ecs/"/>
    <category term="concurrency" scheme="https://edwardshen125.github.io/tags/concurrency/"/>
    <category term="performance" scheme="https://edwardshen125.github.io/tags/performance/"/>
    <content>
      <![CDATA[<p>During load testing, room ticks began overrunning their 50 ms budgetas simulated clients reached target density. Normal ticks took 10–20 ms;spikes took 100–300 ms, leaving clients two to six ticks behind. We hadbuilt the room service for a QQ-licensed casual pet game. It wasdesigned for tens of millions of users; actual peak concurrency reachedroughly 200,000 players.</p><p>I led the architecture selection and ECS implementation. Profilingshowed how much CPU time component lookups consumed.</p><span id="more"></span><h2 id="designing-for-10000-players-per-instance">Designing for 10,000players per instance</h2><p>Tencent's publishing team planned to roll out access in stages. Theservice needed to absorb each traffic increase by adding instances. Loadtests used a fleet of slightly lower-spec instances running bots throughthe real client protocol: entering rooms, moving, and collecting. Thetarget was 10,000 real players per server instance: about 667 rooms withfifteen players each, updated at 20 Hz.</p><p>Late room-state updates led us to investigate the room's serialupdate cycle.</p><p>Outside rooms, another service handled idle collection throughrequest-response calls. Inside rooms, players shared a grid of up to200×200 cells for movement, exploration, combat, and collection.Clicking a destination triggered server-side pathfinding. Playermovement step size was defined separately from the simulation tick. Aroom held about fifteen real players, 30+ combat NPCs, and a varyingnumber of bot players, with a room capacity of 30–100, depending on roomclass. The room service updated shared state on each tick, with threeconstraints:</p><ul><li>Responsive movement. Delayed server updates were visible toplayers.</li><li>A bounded update cycle per room. Each room updated every 50 ms (20Hz), and all work for that tick had to finish within the interval.</li><li>Bounded broadcast fanout. Room population and visibility limits hadto cap the audience for each update.</li></ul><h2 id="choosing-the-room-boundary-and-its-execution-model">Choosing theroom boundary and its execution model</h2><p>I made two choices: which state each execution unit would own, andhow it would run the simulation. <ahref="https://doc.akka.io/docs/akka/2.5/guide/actors-intro.html">Actor-styleownership</a> determined who held mutable state and how work reached it.<ahref="https://www.richardlord.net/blog/ecs/what-is-an-entity-framework.html">ECS</a>determined how components were stored and systems iterated overthem.</p><h3 id="keeping-shared-state-within-one-room">Keeping shared statewithin one room</h3><p>Giving each player its own actor would separate ownership of playerstate, but interactions involving multiple players and shared room statewould require coordination between actors. For example, if two playersclaimed the same collection cell within one tick, an actorimplementation could resolve the conflict by giving a room actorauthority over the cell and routing both claims to it. The cost isforwarding requests, returning results, and defining when the playeractors apply those results. Visibility queries, movement collisions, andPvP interactions raise the same ownership question. I kept theinteracting state under one room owner, so these operations couldcomplete within one simulation pass.</p><p>An actor-based design can also batch inputs and resolve them in astable order; <ahref="https://github.com/asynkronit/protoactor-go">Proto.Actor</a>provides a Go implementation of actor messaging. When player state issplit across actors, the design must also define tick boundaries andcoordinate the state used in each tick.</p><p>A single global simulation loop would couple unrelated rooms: a slowroom would delay the others, and the serial loop could not use multipleprocessors.</p><h3 id="choosing-ecs-for-the-room-interior">Choosing ECS for the roominterior</h3><p>The closest alternative was a room-level actor: one goroutine andmailbox per room, with entities stored as objects and events handled bymethods. It provided the same ownership boundary as the shipped design.In the candidate design, handlers processed messages in arrival orderand dispatched each event separately. My tick-based design batched theframe's inputs and let systems query components directly for operationssuch as finding the players within an area of interest (AOI).</p><p>I chose ECS to support frequent gameplay changes inside existingrooms. Components held buff effects and expiration times; systemsapplied the effects in tick order. Items could reuse the same componentsand systems. Adding mechanics this way reduced branching in playerobjects and message handlers.</p><p>I could order buff application, movement, damage calculation, andexpiration explicitly, with no concurrent writes inside the room. Fixedinputs and simulation time made interactions between mechanics easier toreproduce in tests.</p><p>The speed-item example below uses an array-based ECS.<code>EntityID</code> indexes the component arrays, and masks recordwhich components are present on each entity. <code>Room.Tick</code> runsitem application, buff expiration, and movement serially. Adding orremoving a component changes membership while retaining itspre-allocated slot.</p><p>Each tick simulates the 50 ms interval beginning at<code>nowMS</code>. Buffs are active on<code>[appliedAt, expiresAt)</code>, so a buff expiring at 100 ms doesnot affect movement over <code>[100, 150)</code>. Durations align withticks, and each player can have one speed buff. Positions usethousandths of a cell; distance depends on speed and elapsed time.</p><p>Save as <code>main.go</code> and run <code>go run main.go</code>:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> main</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> <span class="string">&quot;fmt&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> EntityID <span class="type">int</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> ComponentMask <span class="type">uint8</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">HasPosition ComponentMask = <span class="number">1</span> &lt;&lt; <span class="literal">iota</span></span><br><span class="line">HasMoveSpeed</span><br><span class="line">HasSpeedBuff</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// Components contain data only.</span></span><br><span class="line"><span class="keyword">type</span> Position <span class="keyword">struct</span>&#123; MilliCells <span class="type">int</span> &#125;</span><br><span class="line"><span class="keyword">type</span> MoveSpeed <span class="keyword">struct</span>&#123; MilliCellsPerSecond <span class="type">int</span> &#125;</span><br><span class="line"><span class="keyword">type</span> SpeedBuff <span class="keyword">struct</span>&#123; Multiplier, ExpiresAtMS <span class="type">int</span> &#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Room owns component arrays; masks record component membership.</span></span><br><span class="line"><span class="comment">// Entity IDs are fixed array indices in this reduced example.</span></span><br><span class="line"><span class="keyword">type</span> Room <span class="keyword">struct</span> &#123;</span><br><span class="line">Masks      []ComponentMask</span><br><span class="line">Positions  []Position</span><br><span class="line">MoveSpeeds []MoveSpeed</span><br><span class="line">SpeedBuffs []SpeedBuff</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *Room)</span></span> Has(id EntityID, required ComponentMask) <span class="type">bool</span> &#123;</span><br><span class="line"><span class="keyword">return</span> r.Masks[id]&amp;required == required</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Query visits only entities with all required components.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *Room)</span></span> Query(required ComponentMask, visit <span class="function"><span class="keyword">func</span><span class="params">(EntityID)</span></span>) &#123;</span><br><span class="line"><span class="keyword">for</span> id := <span class="keyword">range</span> r.Masks &#123;</span><br><span class="line"><span class="keyword">if</span> r.Has(EntityID(id), required) &#123;</span><br><span class="line">visit(EntityID(id))</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> UseSpeedItem <span class="keyword">struct</span> &#123;</span><br><span class="line">Target                 EntityID</span><br><span class="line">Multiplier, DurationMS <span class="type">int</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">SpeedItemSystem</span><span class="params">(r *Room, nowMS <span class="type">int</span>, commands []UseSpeedItem)</span></span> &#123;</span><br><span class="line"><span class="keyword">for</span> _, cmd := <span class="keyword">range</span> commands &#123;</span><br><span class="line"><span class="keyword">if</span> !r.Has(cmd.Target, HasMoveSpeed) &#123;</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line">r.SpeedBuffs[cmd.Target] = SpeedBuff&#123;cmd.Multiplier, nowMS + cmd.DurationMS&#125;</span><br><span class="line">r.Masks[cmd.Target] |= HasSpeedBuff <span class="comment">// add component</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">BuffExpirySystem</span><span class="params">(r *Room, nowMS <span class="type">int</span>)</span></span> &#123;</span><br><span class="line">r.Query(HasSpeedBuff, <span class="function"><span class="keyword">func</span><span class="params">(id EntityID)</span></span> &#123;</span><br><span class="line"><span class="keyword">if</span> nowMS &gt;= r.SpeedBuffs[id].ExpiresAtMS &#123;</span><br><span class="line">r.Masks[id] &amp;^= HasSpeedBuff <span class="comment">// remove component</span></span><br><span class="line">r.SpeedBuffs[id] = SpeedBuff&#123;&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">MovementSystem</span><span class="params">(r *Room, dtMS <span class="type">int</span>)</span></span> &#123;</span><br><span class="line">r.Query(HasPosition|HasMoveSpeed, <span class="function"><span class="keyword">func</span><span class="params">(id EntityID)</span></span> &#123;</span><br><span class="line">speed := r.MoveSpeeds[id].MilliCellsPerSecond</span><br><span class="line"><span class="keyword">if</span> r.Has(id, HasSpeedBuff) &#123;</span><br><span class="line">speed *= r.SpeedBuffs[id].Multiplier</span><br><span class="line">&#125;</span><br><span class="line">r.Positions[id].MilliCells += speed * dtMS / <span class="number">1000</span></span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *Room)</span></span> Tick(nowMS, dtMS <span class="type">int</span>, commands []UseSpeedItem) &#123;</span><br><span class="line">SpeedItemSystem(r, nowMS, commands)</span><br><span class="line">BuffExpirySystem(r, nowMS)</span><br><span class="line">MovementSystem(r, dtMS)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">main</span><span class="params">()</span></span> &#123;</span><br><span class="line"><span class="keyword">const</span> player, control, stationary EntityID = <span class="number">0</span>, <span class="number">1</span>, <span class="number">2</span></span><br><span class="line">r := Room&#123;</span><br><span class="line">Masks:      []ComponentMask&#123;HasPosition | HasMoveSpeed, HasPosition | HasMoveSpeed, HasPosition&#125;,</span><br><span class="line">Positions:  <span class="built_in">make</span>([]Position, <span class="number">3</span>),</span><br><span class="line">MoveSpeeds: []MoveSpeed&#123;&#123;<span class="number">1000</span>&#125;, &#123;<span class="number">1000</span>&#125;, &#123;&#125;&#125;,</span><br><span class="line">SpeedBuffs: <span class="built_in">make</span>([]SpeedBuff, <span class="number">3</span>),</span><br><span class="line">&#125;</span><br><span class="line">want := []<span class="type">int</span>&#123;<span class="number">100</span>, <span class="number">200</span>, <span class="number">250</span>, <span class="number">300</span>&#125;</span><br><span class="line"><span class="keyword">for</span> step, nowMS := <span class="keyword">range</span> []<span class="type">int</span>&#123;<span class="number">0</span>, <span class="number">50</span>, <span class="number">100</span>, <span class="number">150</span>&#125; &#123;</span><br><span class="line"><span class="keyword">var</span> commands []UseSpeedItem</span><br><span class="line"><span class="keyword">if</span> step == <span class="number">0</span> &#123;</span><br><span class="line">commands = []UseSpeedItem&#123;&#123;Target: player, Multiplier: <span class="number">2</span>, DurationMS: <span class="number">100</span>&#125;&#125;</span><br><span class="line">&#125;</span><br><span class="line">r.Tick(nowMS, <span class="number">50</span>, commands)</span><br><span class="line"><span class="keyword">if</span> r.Positions[player].MilliCells != want[step] ||</span><br><span class="line">r.Positions[control].MilliCells != (step+<span class="number">1</span>)*<span class="number">50</span> ||</span><br><span class="line">r.Positions[stationary].MilliCells != <span class="number">0</span> ||</span><br><span class="line">r.Has(player, HasSpeedBuff) != (nowMS &lt; <span class="number">100</span>) &#123;</span><br><span class="line"><span class="built_in">panic</span>(fmt.Sprintf(<span class="string">&quot;unexpected state at %d ms: %+v&quot;</span>, nowMS, r))</span><br><span class="line">&#125;</span><br><span class="line">fmt.Printf(<span class="string">&quot;[%d,%d) ms: positions=%d,%d,%d buff=%t\n&quot;</span>,</span><br><span class="line">nowMS, nowMS+<span class="number">50</span>, r.Positions[player].MilliCells,</span><br><span class="line">r.Positions[control].MilliCells, r.Positions[stationary].MilliCells,</span><br><span class="line">r.Has(player, HasSpeedBuff))</span><br><span class="line">&#125;</span><br><span class="line">fmt.Println(<span class="string">&quot;PASS: component add/remove, expiry boundary, and movement query&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Output (positions in thousandths of a cell):</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">[0,50) ms: positions=100,50,0 buff=true</span><br><span class="line">[50,100) ms: positions=200,100,0 buff=true</span><br><span class="line">[100,150) ms: positions=250,150,0 buff=false</span><br><span class="line">[150,200) ms: positions=300,200,0 buff=false</span><br><span class="line">PASS: component add/remove, expiry boundary, and movement query</span><br></pre></td></tr></table></figure><p>The position-only entity stays still, and the control player keepsits base speed. The buffed player returns to base speed at theexpiration boundary.</p><p>A room-level actor could implement the same gameplay and tests,including ECS internally. In the candidate's event-handler design,interactions between effects would require ordering rules acrosshandlers. I chose component composition and an explicit system sequenceto keep those rules together as gameplay grew.</p><p>In the shipped service, one goroutine owned each room's mutablestate, and <span class="exturl" data-url="aHR0cHM6Ly9nb2xhbmcub3JnL3BrZy9ydW50aW1lLyNHT01BWFBST0NT">Go'sruntime scheduled room goroutines across the available processors<i class="fa fa-external-link-alt"></i></span>.Systems ran serially within that goroutine, accessing components for therelevant entities on every tick.</p><p>Cross-room state lived on a separate tier with its own consistencyrules, so features spanning rooms required additional coordination.</p><p>A slow entity could delay its room's tick while unrelated roomscontinued, provided the process and scheduler stayed healthy. Extracores could run other rooms, but could not shorten that room's serialtick. A room that exceeded its deadline needed less work per tick,further optimization, or a lower population cap.</p><h2 id="room-scheduling-serial-ticks-and-state-management">Roomscheduling, serial ticks, and state management</h2><img data-src="/posts/engineering-case-study/running-a-million-dau-casual-game-on-go-rooms-as-the-concurrency-boundary/architecture.png" class="" title="One Go instance, room goroutines scheduled across cores, serial tick-driven ECS inside each room"><p>Each instance pooled rooms, pre-allocating them to keep allocationoff the hot path and recycling them as demand fell. Players joined roomclasses matching their level and intent, keeping new players amongpeers. A central scheduler tracked instance load and placed each newroom on the least-loaded instance.</p><p>I initially stored components in entity-keyed maps to supportchanging entity counts without reserving full-capacity arrays for everyroom. Rooms with fewer entities could allocate storage as needed, thoughmaps carried their own bucket and key overhead. Each system looked upcomponents while iterating its entities. Player input arrived throughthe connection layer; each tick consumed the inputs accumulated sincethe previous update.</p><p>State changes were sent only to players whose area of interestincluded the affected location. PvP interaction used the same visibilityboundary. AOI filtering and room population caps bounded broadcastfanout.</p><p>Persistence ran outside the tick. Room snapshots were takenasynchronously and flushed periodically to <ahref="https://docs.mongodb.com/manual/">MongoDB</a>. A restart restoredeach room from its last persisted snapshot, potentially losingsubsequent updates. That was acceptable for this casual game. Snapshotflush lag and the recovery loss window were not measured; I would addmetrics for both.</p><h2 id="map-lookups-exhausted-the-tick-budget">Map lookups exhausted thetick budget</h2><p><span class="exturl" data-url="aHR0cHM6Ly9ibG9nLmdvbGFuZy5vcmcvcHJvZmlsaW5nLWdvLXByb2dyYW1z">pprof<i class="fa fa-external-link-alt"></i></span>showed map operations consuming 60–85% of the profiled CPU time. Thesefigures reflect the load-test results I confirmed at the time; they arenot quoted from retained profiles. The hot frame was<code>aeshash</code>, Go's AES-NI-based hash implementation. The Go 1.13runtime routed <ahref="https://github.com/golang/go/blob/go1.13/src/runtime/map.go">maplookups</a> through its <ahref="https://github.com/golang/go/blob/go1.13/src/runtime/alg.go">hashingfunctions</a> to <ahref="https://github.com/golang/go/blob/go1.13/src/runtime/asm_amd64.s">AESENC-basedcode</a>.</p><p>Across all systems and rooms, component lookups required roughly 13million hash computations per second per instance, before buckettraversal and key comparison. Map insertions and growth addedallocations. GC ran about once a second during the test. Entity IDscould directly index component arrays; the maps added a hash lookup toeach access.</p><p>I kept the room boundary and serial system order, and replacedcomponent maps with pre-allocated fixed-capacity arrays indexed byentity ID. Storage was reused across room lifecycles. Sequentialcomponent traversal also favored cache locality, though cache misseswere not measured separately.</p><p>Pre-allocated arrays reserved space up to each room's entity cap evenwhen occupancy was low. Room reuse saved allocation work but retainedthat storage. Growing arrays with occupancy or grouping entities bycomponent set could reduce unused slots, but would addstorage-management work. I accepted the reserved space to keep lookupsdirect and reuse storage.</p><p>After the change, p99 tick elapsed time, measured with<code>time.Since</code>, fell below 50 ms, and instance CPU utilizationsettled near 70%. Profiles showed less hot-path allocation churn, but Ido not have before-and-after measurements of GC pause frequency.</p><p>Broadcast fanout was not the bottleneck in this test. The tickoverruns were resolved before production.</p><h2 id="production-results-and-capacity">Production results andcapacity</h2><p>At 10,000 players per instance and fifteen players per room, eachinstance ran about 667 rooms at 20 Hz. A typical room contained roughly60–80 entities: players, combat NPCs, bots, and collection objects. Atthat density, the 8-vCPU instance averaged 70% CPU utilization duringthe same load-test window. Dividing its CPU consumption by the number ofroom ticks gives about 0.42 ms per room tick:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">CPU consumption = 8 vCPU × 70% = 5.6 CPU-seconds per second</span><br><span class="line">room tick rate  = 667 rooms × 20 ticks/second = 13,340 room ticks/second</span><br><span class="line">CPU per room tick = 5.6 / 13,340 seconds ≈ 0.42 ms</span><br></pre></td></tr></table></figure><p>This average includes connection handling, serialization, GC, andother instance work.</p><p>Room goroutines waited between updates, allowing hundreds of rooms toshare the 8 vCPUs. We added instances as room density approached theper-instance limit.</p><p>The memory model was:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">memory = process_baseline + rooms × room_memory + buffers + headroom</span><br></pre></td></tr></table></figure><p>Estimated per-room memory was a few hundred KiB at fifteen players.At ~667 rooms per instance, that amounted to a few hundred MiB. The 16GiB instances also provided capacity for the Go process, playerconnections, queues, snapshots, caches, runtime metadata, and headroom.The fleet used the same instance configuration throughout: 8 vCPU / 16GiB on <ahref="https://cloud.tencent.com/document/product/213/11518">TencentCloud</a>.</p><p>Concurrency outside activity peaks was in the tens of thousands. Asingle instance carried over 10,000 concurrent players. At activitypeaks, the fleet served roughly 200,000 concurrent players across 20+instances, with over one million DAU.</p><hr /><p><em>The talk <ahref="https://www.youtube.com/watch?v=W3aieHjyNvw">Overwatch GameplayArchitecture and Netcode</a> gave me many of the ideas behind thisdesign, especially separating component state from system behavior andorganizing data for sequential access.</em></p><p>**Next in this series: <ahref="/posts/engineering-case-study/one-queue-for-every-state-change-fixing-silent-over-assignment-in-a-live-qr-code-system/">OneQueue for Every State Change: Fixing Silent Over-Assignment in a Live QRCode System</a>.<em>.</em></p>]]>
    </content>
    <id>https://edwardshen125.github.io/posts/engineering-case-study/running-a-million-dau-casual-game-on-go-rooms-as-the-concurrency-boundary/</id>
    <link href="https://edwardshen125.github.io/posts/engineering-case-study/running-a-million-dau-casual-game-on-go-rooms-as-the-concurrency-boundary/"/>
    <published>2020-01-25T02:00:00.000Z</published>
    <summary>Go room-server architecture for a casual game with over one million DAU: serial ECS ticks, room-level concurrency, and map-to-array optimization.</summary>
    <title>Running a Million-DAU Casual Game on Go: Rooms as the Concurrency Boundary</title>
    <updated>2026-10-25T08:31:43.000Z</updated>
  </entry>
</feed>
