<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Appendrow on Tarragon</title><link>https://tarrragon.github.io/blog/tags/appendrow/</link><description>Recent content in Appendrow on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 06 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/appendrow/index.xml" rel="self" type="application/rss+xml"/><item><title>寫入與並發：appendRow 與 LockService</title><link>https://tarrragon.github.io/blog/automation/03-sheet-as-database/append-and-concurrency/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/03-sheet-as-database/append-and-concurrency/</guid><description>&lt;p>Sheet 的寫入層要處理的核心問題是：當多個 beacon 幾乎同時打進來、各自觸發一個 &lt;code>doPost&lt;/code> 執行實例、都要往同一張表寫一列時，這些並發寫入會不會互相干擾。單筆寫入很單純，並發才是這一層真正要判斷的地方。先講單筆怎麼寫，再講並發何時需要保護。&lt;/p>
&lt;h2 id="appendrow單筆寫入">appendRow：單筆寫入&lt;/h2>
&lt;p>&lt;code>appendRow&lt;/code> 在表格最後一列的後面新增一列，是 log 型資料最自然的寫法——每個事件就是一列、append 到尾巴。它一次接一個陣列、對應各欄：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendRow&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="k">new&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dev&lt;/span>&lt;span class="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>appendRow&lt;/code> 的好處是它自己找「最後一列的下一列」，不必你算行號。對「一次寫一筆瀏覽」這種場景，它是最直接的選擇。要注意它是「整列 append」——如果需要一次寫很多列（例如批次彙總），逐筆呼叫 &lt;code>appendRow&lt;/code> 會慢，那時改用 &lt;code>getRange(...).setValues(二維陣列)&lt;/code> 一次寫一整塊，效能差很多。單筆用 &lt;code>appendRow&lt;/code>、批次用 &lt;code>setValues&lt;/code>，是 Sheets 寫入的基本分工。&lt;/p>
&lt;h2 id="並發兩個-dopost-同時-append-會怎樣">並發：兩個 doPost 同時 append 會怎樣&lt;/h2>
&lt;p>Apps Script 允許多個執行實例同時跑（個人帳號上限 30 個併發）。當兩個 beacon 幾乎同時到達，平台會起兩個 &lt;code>doPost&lt;/code> 實例並行執行，兩者都呼叫 &lt;code>appendRow&lt;/code>。多數情況下 Google 會把兩次 append 排到不同列、相安無事；但在高並發下，「兩個實例同時判斷『最後一列是第 100 列』、都想寫第 101 列」的競態是可能發生的，結果是一筆覆蓋另一筆、少記一筆。&lt;/p>
&lt;p>這個風險要不要處理，取決於&lt;strong>你的瞬間併發有多高&lt;/strong>。個人 blog 的流量分散在整天，任何一刻同時到達的 beacon 通常是個位數甚至零，競態機率極低、就算偶爾少記一筆對「哪篇有人看」的判斷也無影響——這種情境不必加保護，保持 &lt;code>appendRow&lt;/code> 的簡單。真正需要處理的是「短時間尖峰」：某篇文章被大量分享、一分鐘湧入幾百次瀏覽，這時並發拉高、競態才變得值得防。&lt;/p>
&lt;h2 id="lockservice需要時才序列化">LockService：需要時才序列化&lt;/h2>
&lt;p>要防競態，用 &lt;code>LockService&lt;/code> 把寫入序列化——讓同一時間只有一個執行實例能進入寫入區段，其他的排隊等它做完：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">doPost&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">lock&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">LockService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getScriptLock&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">lock&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">waitLock&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">5000&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// 最多等 5 秒拿鎖
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">JSON&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">postData&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">contents&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">SpreadsheetApp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getActiveSpreadsheet&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">getSheetByName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;工作表1&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">appendRow&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="k">new&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dev&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">finally&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">lock&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">releaseLock&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 一定要放，否則後面全部卡住
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ContentService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">createTextOutput&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">JSON&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">stringify&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">ok&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">}))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">setMimeType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ContentService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MimeType&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JSON&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>getScriptLock&lt;/code> 取的是整個腳本共用的鎖，保證所有 &lt;code>doPost&lt;/code> 實例排隊寫入。&lt;code>waitLock(5000)&lt;/code> 是「最多等 5 秒」——等不到就丟例外（併發高到 5 秒還排不進來，這筆放棄，總比無限等好）。&lt;code>releaseLock&lt;/code> 必須放在 &lt;code>finally&lt;/code>，確保就算中間出錯也會釋放，否則鎖沒放、後面的請求全部卡死。&lt;/p>
&lt;p>加鎖的代價是寫入從並行變序列，尖峰時每筆要多等前面的做完，吞吐下降。所以它是「有並發競態風險時才加」的保護，不是預設就要有的東西。判斷訊號回到前面那句：&lt;strong>瞬間併發會不會高到讓競態實際發生&lt;/strong>——不會就別加，會就加上。這也是為什麼流量統計的基本版 &lt;code>doPost&lt;/code>（模組二）刻意不含 lock：先讓簡單版跑起來，等真的遇到尖峰漏記，再加這一層。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>寫入處理好了，資料表本身怎麼設計、以及 Sheets 累積到多大會撐不住，見&lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/data-model-and-capacity/" data-link-title="資料模型與容量邊界" data-link-desc="raw log 表的欄位怎麼設計才好彙總、以及 Sheets 累積到多少列會開始撐不住、撐不住的訊號長什麼樣">資料模型與容量邊界&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Sheet 的寫入層要處理的核心問題是：當多個 beacon 幾乎同時打進來、各自觸發一個 <code>doPost</code> 執行實例、都要往同一張表寫一列時，這些並發寫入會不會互相干擾。單筆寫入很單純，並發才是這一層真正要判斷的地方。先講單筆怎麼寫，再講並發何時需要保護。</p>
<h2 id="appendrow單筆寫入">appendRow：單筆寫入</h2>
<p><code>appendRow</code> 在表格最後一列的後面新增一列，是 log 型資料最自然的寫法——每個事件就是一列、append 到尾巴。它一次接一個陣列、對應各欄：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">sheet</span><span class="p">.</span><span class="nx">appendRow</span><span class="p">([</span><span class="k">new</span> <span class="nb">Date</span><span class="p">(),</span> <span class="nx">data</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">dev</span><span class="p">]);</span></span></span></code></pre></div><p><code>appendRow</code> 的好處是它自己找「最後一列的下一列」，不必你算行號。對「一次寫一筆瀏覽」這種場景，它是最直接的選擇。要注意它是「整列 append」——如果需要一次寫很多列（例如批次彙總），逐筆呼叫 <code>appendRow</code> 會慢，那時改用 <code>getRange(...).setValues(二維陣列)</code> 一次寫一整塊，效能差很多。單筆用 <code>appendRow</code>、批次用 <code>setValues</code>，是 Sheets 寫入的基本分工。</p>
<h2 id="並發兩個-dopost-同時-append-會怎樣">並發：兩個 doPost 同時 append 會怎樣</h2>
<p>Apps Script 允許多個執行實例同時跑（個人帳號上限 30 個併發）。當兩個 beacon 幾乎同時到達，平台會起兩個 <code>doPost</code> 實例並行執行，兩者都呼叫 <code>appendRow</code>。多數情況下 Google 會把兩次 append 排到不同列、相安無事；但在高並發下，「兩個實例同時判斷『最後一列是第 100 列』、都想寫第 101 列」的競態是可能發生的，結果是一筆覆蓋另一筆、少記一筆。</p>
<p>這個風險要不要處理，取決於<strong>你的瞬間併發有多高</strong>。個人 blog 的流量分散在整天，任何一刻同時到達的 beacon 通常是個位數甚至零，競態機率極低、就算偶爾少記一筆對「哪篇有人看」的判斷也無影響——這種情境不必加保護，保持 <code>appendRow</code> 的簡單。真正需要處理的是「短時間尖峰」：某篇文章被大量分享、一分鐘湧入幾百次瀏覽，這時並發拉高、競態才變得值得防。</p>
<h2 id="lockservice需要時才序列化">LockService：需要時才序列化</h2>
<p>要防競態，用 <code>LockService</code> 把寫入序列化——讓同一時間只有一個執行實例能進入寫入區段，其他的排隊等它做完：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">doPost</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">lock</span> <span class="o">=</span> <span class="nx">LockService</span><span class="p">.</span><span class="nx">getScriptLock</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">lock</span><span class="p">.</span><span class="nx">waitLock</span><span class="p">(</span><span class="mi">5000</span><span class="p">);</span> <span class="c1">// 最多等 5 秒拿鎖
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">var</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">postData</span><span class="p">.</span><span class="nx">contents</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">SpreadsheetApp</span><span class="p">.</span><span class="nx">getActiveSpreadsheet</span><span class="p">().</span><span class="nx">getSheetByName</span><span class="p">(</span><span class="s2">&#34;工作表1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="p">.</span><span class="nx">appendRow</span><span class="p">([</span><span class="k">new</span> <span class="nb">Date</span><span class="p">(),</span> <span class="nx">data</span><span class="p">.</span><span class="nx">path</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">dev</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">}</span> <span class="k">finally</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">lock</span><span class="p">.</span><span class="nx">releaseLock</span><span class="p">();</span> <span class="c1">// 一定要放，否則後面全部卡住
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">return</span> <span class="nx">ContentService</span><span class="p">.</span><span class="nx">createTextOutput</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="nx">ok</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}))</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">.</span><span class="nx">setMimeType</span><span class="p">(</span><span class="nx">ContentService</span><span class="p">.</span><span class="nx">MimeType</span><span class="p">.</span><span class="nx">JSON</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>getScriptLock</code> 取的是整個腳本共用的鎖，保證所有 <code>doPost</code> 實例排隊寫入。<code>waitLock(5000)</code> 是「最多等 5 秒」——等不到就丟例外（併發高到 5 秒還排不進來，這筆放棄，總比無限等好）。<code>releaseLock</code> 必須放在 <code>finally</code>，確保就算中間出錯也會釋放，否則鎖沒放、後面的請求全部卡死。</p>
<p>加鎖的代價是寫入從並行變序列，尖峰時每筆要多等前面的做完，吞吐下降。所以它是「有並發競態風險時才加」的保護，不是預設就要有的東西。判斷訊號回到前面那句：<strong>瞬間併發會不會高到讓競態實際發生</strong>——不會就別加，會就加上。這也是為什麼流量統計的基本版 <code>doPost</code>（模組二）刻意不含 lock：先讓簡單版跑起來，等真的遇到尖峰漏記，再加這一層。</p>
<h2 id="下一步">下一步</h2>
<p>寫入處理好了，資料表本身怎麼設計、以及 Sheets 累積到多大會撐不住，見<a href="/blog/automation/03-sheet-as-database/data-model-and-capacity/" data-link-title="資料模型與容量邊界" data-link-desc="raw log 表的欄位怎麼設計才好彙總、以及 Sheets 累積到多少列會開始撐不住、撐不住的訊號長什麼樣">資料模型與容量邊界</a>。</p>
]]></content:encoded></item><item><title>接收端 handler：寫進第一筆</title><link>https://tarrragon.github.io/blog/automation/02-analytics-beacon/receiver-handler/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/02-analytics-beacon/receiver-handler/</guid><description>&lt;p>接收端的責任是：接住前端送來的 beacon，把它解析成一筆結構化紀錄，安全地 append 進 Google Sheet，然後回一個簡短回應。這一篇從一張空白試算表開始，做到部署後打開試算表看到第一列瀏覽紀錄。前端那半見&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙&lt;/a>，這裡假設 beacon 已經在送。&lt;/p>
&lt;h2 id="準備一張-sheet-當儲存體">準備一張 Sheet 當儲存體&lt;/h2>
&lt;p>先在 Google Sheets 建一張新試算表，命名例如 &lt;code>blog-analytics&lt;/code>。第一列放欄位標題，對應接收端要寫的欄位：&lt;code>時間&lt;/code>、&lt;code>路徑&lt;/code>、&lt;code>來源&lt;/code>、&lt;code>語言&lt;/code>、&lt;code>裝置&lt;/code>。這一列標題只是給人看的，接收端從第二列開始 append 資料。&lt;/p>
&lt;p>Apps Script 有兩種綁定方式，這裡用&lt;strong>容器綁定&lt;/strong>：直接在這張試算表的選單開 &lt;code>擴充功能 → Apps Script&lt;/code>，開出來的 script 專案天生就跟這張試算表綁在一起，程式裡用 &lt;code>SpreadsheetApp.getActiveSpreadsheet()&lt;/code> 就能拿到它，不必記試算表 ID。兩種綁定方式的差異、以及什麼時候該用獨立專案，見&lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基&lt;/a>。&lt;/p>
&lt;h2 id="寫-dopost-接住-beacon">寫 doPost 接住 beacon&lt;/h2>
&lt;p>前端用 &lt;code>sendBeacon&lt;/code> 送 &lt;code>POST&lt;/code>，所以接收端要實作 &lt;code>doPost&lt;/code>。&lt;code>sendBeacon&lt;/code> 送來的內容放在 &lt;code>e.postData.contents&lt;/code>，是一個 JSON 字串（前端刻意用 &lt;code>text/plain&lt;/code> 傳輸、內容仍是 JSON），接收端 &lt;code>JSON.parse&lt;/code> 把它還原：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">doPost&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">JSON&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">postData&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">contents&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">sheet&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">SpreadsheetApp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getActiveSpreadsheet&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">getSheetByName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;工作表1&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendRow&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">new&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="c1">// 時間：用伺服器時間，不信任前端傳的時間
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 路徑
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ref&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 來源
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">lang&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 語言
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dev&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 裝置：mobile / tablet / desktop
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ContentService&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">createTextOutput&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">JSON&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">stringify&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">ok&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">}))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">setMimeType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ContentService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MimeType&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JSON&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">catch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ContentService&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">createTextOutput&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">JSON&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">stringify&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">ok&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">error&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">String&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">}))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">setMimeType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ContentService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MimeType&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JSON&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個實作決定值得說明：&lt;/p>
&lt;p>&lt;strong>時間用伺服器的 &lt;code>new Date()&lt;/code>，不用前端傳的時間。&lt;/strong> 前端時間來自使用者的裝置，時區、時鐘準不準都不可控，還多一個可被偽造的欄位。接收端執行的當下時間就是最接近真實的瀏覽時間，用它最乾淨。這也是前端 payload 不需要送時間戳的原因。&lt;/p>
&lt;p>&lt;strong>每個欄位都用 &lt;code>|| &amp;quot;&amp;quot;&lt;/code> 給預設值。&lt;/strong> beacon 有可能因為前端狀況送來缺欄位的資料，&lt;code>data.path&lt;/code> 若是 &lt;code>undefined&lt;/code>，直接寫進 Sheet 會是空格但不會報錯；用 &lt;code>|| &amp;quot;&amp;quot;&lt;/code> 明確寫成空字串，讓資料形狀一致，之後彙總不會踩到 &lt;code>undefined&lt;/code>。&lt;/p>
&lt;p>&lt;strong>整段包在 &lt;code>try/catch&lt;/code> 裡。&lt;/strong> 接收端可能收到格式壞掉的請求（爬蟲亂打、payload 不是合法 JSON）。&lt;code>JSON.parse&lt;/code> 遇到壞資料會丟例外，沒接住的話這次執行算失敗、也可能讓錯誤累積。包起來後，壞請求就回一個 &lt;code>ok: false&lt;/code>、不影響服務。真正在意這些壞請求要不要防、怎麼防，見模組五。&lt;/p>
&lt;p>回應用 &lt;code>ContentService.createTextOutput&lt;/code> 回一小段 JSON。&lt;code>sendBeacon&lt;/code> 是 fire-and-forget、前端根本不會讀這個回應，所以回什麼不重要——但 &lt;code>doPost&lt;/code> 必須回一個 &lt;code>ContentService&lt;/code> 的輸出，這是 Apps Script web app 的硬性要求，不回會被當成執行沒有正常結束。&lt;/p>
&lt;h2 id="部署成-web-app">部署成 web app&lt;/h2>
&lt;p>程式寫好還打不通，因為它還沒有對外網址。在 Apps Script 編輯器右上角 &lt;code>部署 → 新增部署作業&lt;/code>，類型選 &lt;code>網頁應用程式&lt;/code>，兩個設定要對：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>執行身分（Execute as）&lt;/strong>：選「我」。beacon 是匿名訪客送來的，他們沒有你試算表的權限；選「我」讓這段程式用你的身分執行，才寫得進你的 Sheet。&lt;/li>
&lt;li>&lt;strong>誰可存取（Who has access）&lt;/strong>：選「所有人」。訪客是沒登入 Google 的匿名瀏覽器，只有「所有人」這個選項能讓匿名 beacon 打得進來。&lt;/li>
&lt;/ul>
&lt;p>部署後會拿到一個網址，形如 &lt;code>https://script.google.com/macros/s/AKfyc.../exec&lt;/code>。把這個網址填回前端 beacon 的 &lt;code>ENDPOINT&lt;/code>（見前端那半）。這兩個設定的安全含義——「所有人可存取」會不會被濫用、要不要加保護——在模組五完整討論；先讓它通，才有東西可以保護。&lt;/p>
&lt;h2 id="確認收到第一筆">確認收到第一筆&lt;/h2>
&lt;p>部署完、前端網址也填好後，打開你的 blog 任一頁（正式網域，不是本機預覽），beacon 就會送出。回到 Google Sheet，第二列應該出現一筆：伺服器時間、你剛看的路徑、來源、語言。看到這一列，整條 client beacon 管線就打通了——前端偵測、跨網域送達、接收端解析、寫進儲存體，每一環都在運作。&lt;/p></description><content:encoded><![CDATA[<p>接收端的責任是：接住前端送來的 beacon，把它解析成一筆結構化紀錄，安全地 append 進 Google Sheet，然後回一個簡短回應。這一篇從一張空白試算表開始，做到部署後打開試算表看到第一列瀏覽紀錄。前端那半見<a href="/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙</a>，這裡假設 beacon 已經在送。</p>
<h2 id="準備一張-sheet-當儲存體">準備一張 Sheet 當儲存體</h2>
<p>先在 Google Sheets 建一張新試算表，命名例如 <code>blog-analytics</code>。第一列放欄位標題，對應接收端要寫的欄位：<code>時間</code>、<code>路徑</code>、<code>來源</code>、<code>語言</code>、<code>裝置</code>。這一列標題只是給人看的，接收端從第二列開始 append 資料。</p>
<p>Apps Script 有兩種綁定方式，這裡用<strong>容器綁定</strong>：直接在這張試算表的選單開 <code>擴充功能 → Apps Script</code>，開出來的 script 專案天生就跟這張試算表綁在一起，程式裡用 <code>SpreadsheetApp.getActiveSpreadsheet()</code> 就能拿到它，不必記試算表 ID。兩種綁定方式的差異、以及什麼時候該用獨立專案，見<a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a>。</p>
<h2 id="寫-dopost-接住-beacon">寫 doPost 接住 beacon</h2>
<p>前端用 <code>sendBeacon</code> 送 <code>POST</code>，所以接收端要實作 <code>doPost</code>。<code>sendBeacon</code> 送來的內容放在 <code>e.postData.contents</code>，是一個 JSON 字串（前端刻意用 <code>text/plain</code> 傳輸、內容仍是 JSON），接收端 <code>JSON.parse</code> 把它還原：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">doPost</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">var</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">postData</span><span class="p">.</span><span class="nx">contents</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">var</span> <span class="nx">sheet</span> <span class="o">=</span> <span class="nx">SpreadsheetApp</span><span class="p">.</span><span class="nx">getActiveSpreadsheet</span><span class="p">().</span><span class="nx">getSheetByName</span><span class="p">(</span><span class="s2">&#34;工作表1&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">sheet</span><span class="p">.</span><span class="nx">appendRow</span><span class="p">([</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="k">new</span> <span class="nb">Date</span><span class="p">(),</span>          <span class="c1">// 時間：用伺服器時間，不信任前端傳的時間
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>      <span class="nx">data</span><span class="p">.</span><span class="nx">path</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>     <span class="c1">// 路徑
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>      <span class="nx">data</span><span class="p">.</span><span class="nx">ref</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>      <span class="c1">// 來源
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>      <span class="nx">data</span><span class="p">.</span><span class="nx">lang</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>     <span class="c1">// 語言
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>      <span class="nx">data</span><span class="p">.</span><span class="nx">dev</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>      <span class="c1">// 裝置：mobile / tablet / desktop
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>    <span class="p">]);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="nx">ContentService</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="p">.</span><span class="nx">createTextOutput</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="nx">ok</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}))</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="p">.</span><span class="nx">setMimeType</span><span class="p">(</span><span class="nx">ContentService</span><span class="p">.</span><span class="nx">MimeType</span><span class="p">.</span><span class="nx">JSON</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="nx">ContentService</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="p">.</span><span class="nx">createTextOutput</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="nx">ok</span><span class="o">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">error</span><span class="o">:</span> <span class="nb">String</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">}))</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">      <span class="p">.</span><span class="nx">setMimeType</span><span class="p">(</span><span class="nx">ContentService</span><span class="p">.</span><span class="nx">MimeType</span><span class="p">.</span><span class="nx">JSON</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>三個實作決定值得說明：</p>
<p><strong>時間用伺服器的 <code>new Date()</code>，不用前端傳的時間。</strong> 前端時間來自使用者的裝置，時區、時鐘準不準都不可控，還多一個可被偽造的欄位。接收端執行的當下時間就是最接近真實的瀏覽時間，用它最乾淨。這也是前端 payload 不需要送時間戳的原因。</p>
<p><strong>每個欄位都用 <code>|| &quot;&quot;</code> 給預設值。</strong> beacon 有可能因為前端狀況送來缺欄位的資料，<code>data.path</code> 若是 <code>undefined</code>，直接寫進 Sheet 會是空格但不會報錯；用 <code>|| &quot;&quot;</code> 明確寫成空字串，讓資料形狀一致，之後彙總不會踩到 <code>undefined</code>。</p>
<p><strong>整段包在 <code>try/catch</code> 裡。</strong> 接收端可能收到格式壞掉的請求（爬蟲亂打、payload 不是合法 JSON）。<code>JSON.parse</code> 遇到壞資料會丟例外，沒接住的話這次執行算失敗、也可能讓錯誤累積。包起來後，壞請求就回一個 <code>ok: false</code>、不影響服務。真正在意這些壞請求要不要防、怎麼防，見模組五。</p>
<p>回應用 <code>ContentService.createTextOutput</code> 回一小段 JSON。<code>sendBeacon</code> 是 fire-and-forget、前端根本不會讀這個回應，所以回什麼不重要——但 <code>doPost</code> 必須回一個 <code>ContentService</code> 的輸出，這是 Apps Script web app 的硬性要求，不回會被當成執行沒有正常結束。</p>
<h2 id="部署成-web-app">部署成 web app</h2>
<p>程式寫好還打不通，因為它還沒有對外網址。在 Apps Script 編輯器右上角 <code>部署 → 新增部署作業</code>，類型選 <code>網頁應用程式</code>，兩個設定要對：</p>
<ul>
<li><strong>執行身分（Execute as）</strong>：選「我」。beacon 是匿名訪客送來的，他們沒有你試算表的權限；選「我」讓這段程式用你的身分執行，才寫得進你的 Sheet。</li>
<li><strong>誰可存取（Who has access）</strong>：選「所有人」。訪客是沒登入 Google 的匿名瀏覽器，只有「所有人」這個選項能讓匿名 beacon 打得進來。</li>
</ul>
<p>部署後會拿到一個網址，形如 <code>https://script.google.com/macros/s/AKfyc.../exec</code>。把這個網址填回前端 beacon 的 <code>ENDPOINT</code>（見前端那半）。這兩個設定的安全含義——「所有人可存取」會不會被濫用、要不要加保護——在模組五完整討論；先讓它通，才有東西可以保護。</p>
<h2 id="確認收到第一筆">確認收到第一筆</h2>
<p>部署完、前端網址也填好後，打開你的 blog 任一頁（正式網域，不是本機預覽），beacon 就會送出。回到 Google Sheet，第二列應該出現一筆：伺服器時間、你剛看的路徑、來源、語言。看到這一列，整條 client beacon 管線就打通了——前端偵測、跨網域送達、接收端解析、寫進儲存體，每一環都在運作。</p>
<p>如果沒出現，最常見的兩個原因：一是前端 <code>ENDPOINT</code> 填成舊的部署網址（每次「新增部署作業」網址會變，改程式後要用「管理部署作業」更新同一個部署、網址才不變），二是部署的存取權限沒設成「所有人」導致匿名 beacon 被擋。這兩個都是設定問題，不是程式問題。</p>
<h2 id="下一步">下一步</h2>
<p>第一筆進來了，往下有兩條路。</p>
<p>想知道<strong>這些資料怎麼讀出意義</strong>——來源網址為什麼大量空白、怎麼判斷兩列是不是同一個人、哪些是機器抓取——走<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a>。那一章會在 payload 上增加欄位，屆時這裡的 <code>appendRow</code> 與試算表的表頭要跟著改，改法在該章的<a href="/blog/automation/06-reading-the-data/#%e6%ac%84%e4%bd%8d%e8%ae%8a%e6%9b%b4%e8%a6%81%e5%90%8c%e6%ad%a5%e5%88%b0%e6%8e%a5%e6%94%b6%e7%ab%af" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">欄位變更清單</a>。</p>
<p>想先把<strong>儲存層打穩</strong>——多個瀏覽在同一瞬間打進來時 <code>appendRow</code> 會不會互相覆蓋、資料列累積到幾萬列時 Sheets 還撐不撐得住——走<a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫</a>。</p>
]]></content:encoded></item><item><title>模組三：Sheets 當資料庫</title><link>https://tarrragon.github.io/blog/automation/03-sheet-as-database/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/03-sheet-as-database/</guid><description>&lt;p>回答「Sheet 當資料庫可以撐到什麼程度、什麼時候會出問題」。Sheets 當儲存體的最大好處是它同時是儀表板——資料進去就能直接看、排序、畫圖。但它畢竟不是為高併發資料庫設計的，這一章處理三件會實際遇到的事：多個 beacon 同時 &lt;code>appendRow&lt;/code> 會不會互相覆蓋、資料欄位怎麼設計才好彙總、以及列數累積到多少會開始變慢。&lt;/p>
&lt;p>Sheets 適合的場景是資料量小到中等、需要人直接讀寫的流量統計。它撐不住的訊號很明確：列數到數萬列後讀寫變慢、瞬間併發逼近 30 時出現寫入衝突。碰到這些訊號時的判斷與遷移路徑，見&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五&lt;/a>。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/append-and-concurrency/" data-link-title="寫入與並發：appendRow 與 LockService" data-link-desc="多個 beacon 同時寫進同一張 Sheet 時，appendRow 的競態風險與 LockService 序列化寫入的時機判斷">寫入與並發：appendRow 與 LockService&lt;/a>&lt;/td>
 &lt;td>單筆 &lt;code>appendRow&lt;/code>、並發競態何時發生、&lt;code>LockService&lt;/code> 序列化的時機判斷&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/data-model-and-capacity/" data-link-title="資料模型與容量邊界" data-link-desc="raw log 表的欄位怎麼設計才好彙總、以及 Sheets 累積到多少列會開始撐不住、撐不住的訊號長什麼樣">資料模型與容量邊界&lt;/a>&lt;/td>
 &lt;td>raw log 的欄位設計、Sheets 的 cell 與效能上限、撐不住的訊號與分表 / 遷移&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">模組二：接收端 handler&lt;/a>：資料怎麼進到 Sheet 的&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程&lt;/a>：把 raw log 彙總成報表&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全&lt;/a>：Sheets 撐不住時怎麼判斷與遷移&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「Sheet 當資料庫可以撐到什麼程度、什麼時候會出問題」。Sheets 當儲存體的最大好處是它同時是儀表板——資料進去就能直接看、排序、畫圖。但它畢竟不是為高併發資料庫設計的，這一章處理三件會實際遇到的事：多個 beacon 同時 <code>appendRow</code> 會不會互相覆蓋、資料欄位怎麼設計才好彙總、以及列數累積到多少會開始變慢。</p>
<p>Sheets 適合的場景是資料量小到中等、需要人直接讀寫的流量統計。它撐不住的訊號很明確：列數到數萬列後讀寫變慢、瞬間併發逼近 30 時出現寫入衝突。碰到這些訊號時的判斷與遷移路徑，見<a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五</a>。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/03-sheet-as-database/append-and-concurrency/" data-link-title="寫入與並發：appendRow 與 LockService" data-link-desc="多個 beacon 同時寫進同一張 Sheet 時，appendRow 的競態風險與 LockService 序列化寫入的時機判斷">寫入與並發：appendRow 與 LockService</a></td>
          <td>單筆 <code>appendRow</code>、並發競態何時發生、<code>LockService</code> 序列化的時機判斷</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/03-sheet-as-database/data-model-and-capacity/" data-link-title="資料模型與容量邊界" data-link-desc="raw log 表的欄位怎麼設計才好彙總、以及 Sheets 累積到多少列會開始撐不住、撐不住的訊號長什麼樣">資料模型與容量邊界</a></td>
          <td>raw log 的欄位設計、Sheets 的 cell 與效能上限、撐不住的訊號與分表 / 遷移</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">模組二：接收端 handler</a>：資料怎麼進到 Sheet 的</li>
<li>→ <a href="/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程</a>：把 raw log 彙總成報表</li>
<li>→ <a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全</a>：Sheets 撐不住時怎麼判斷與遷移</li>
</ul>
]]></content:encoded></item></channel></rss>