PHP APCu快取使用與避坑

小松聊PHP进阶發表於2024-05-20

APCu

  • 極簡概括: PHP 的開源記憶體快取擴充套件,類比Redis,但是一般都用Redis,所以APCu用的很少。
  • 官方文件:https://www.php.net/manual/zh/apcu.configuration.php
  • 解決問題:類比Redis做快取元件,提升效能,同步資料使用。
  • 適用場景:輕量級的快取,適合寫少讀多的場景。缺少原子性、缺少多條指令無間隙執行,不建議高併發時寫多讀多,寫多讀少的場景下使用。
  • 優點:
    • 比Redis快一百多倍。
    • 運維成本低:利用PHP擴充套件的方式實現,無需與快取元件進行網路通訊。
    • 簡單易用:APCu 提供了簡單而有效的介面,容易上手。
    • 跨檔案跨程序:A檔案set值,B檔案get值,是可以獲取到值的,若做不到和變數沒區別。
  • 缺點:
    • 不支援遠端獨立部署。
    • 型別沒有Redis的多,適用場景僅限於快取。
    • 資料無法做到常駐記憶體,重啟或出故障,資料丟了就沒了,沒有像Redis的RDB或AOF持久化機制。
    • 無法保證多個操作的原子性。
    • 可能存在超賣的問題。
    • 獲取確定已經存在的值可能會遇到false,獲取結果不穩定。
  • 注意:從PHP 8.0.0開始,不再支援apcu bc。

是否能像Redis+Lua一樣保證多個操作原子性?

不能。
Redis是單執行緒的,意味著單位時間內只執行一個任務,Redis讓併發過來的任務強制序列執行。有Lua的加持,保證多條指令無間隙執行。
APCu沒有這個機制,讓操作要麼都成功,要麼都失敗,要實現這一步需要手動寫邏輯。

高併發下會有超賣的一致性問題嗎?

還好APCu有樂觀鎖機制,可以防止超賣問題。

但APCu 沒有互斥的鎖機制,互斥意味著併發過來的請求,透過獨佔該資源,讓任務序列執行。

由於PHP+Nginx預設是多程序機制,(也可以調整為多執行緒,用得少)
假設一個場景:
獲取到值,自增。程序P0獲取到A的值為5的時候,想要自增到6,可能其它程序已經自增到8了。此時兩步操作存在間隙,又沒有機制對此資料加鎖防止被其它程序更改,所以可能P0執行自增時會加到9,這是個機率問題,因此樂觀鎖的機制就顯得非常重要。

安裝

前提是安裝好了PHP,預設在/usr/local/php下,並配置有/usr/local/php/bin目錄的環境變數
cd /test
wget https://pecl.php.net/get/apcu-5.1.23.tgz
tar zxf apcu-5.1.23.tgz
phpize
./configure
make
make install

相關配置(php.ini)

配置名 值型別 預設值 說明
apc.enabled int 1 設定為0以禁用APC。這在APC被靜態編譯到PHP中時非常有用,因為沒有其他方法可以禁用它。
apc.shm_segments int 1 為編譯器快取分配的共享記憶體段的數量。如果APC共享記憶體不足,但apc.shm_size設定為系統允許的最高值,提高該值可能會防止APC耗盡其記憶體。
apc.shm_size int 32M 每個共享記憶體段的大小
apc.entries_hint int 4096 是用於設定APCu快取的條目預期數量
apc.ttl int 0 指定快取中的條目在過期之前可以存在多長時間,單位為秒。預設為0,表示永不過期
apc.gc_ttl int 3600 指定過期快取條目被清理的時間間隔,單位為秒
apc.mmap_file_mask string null 是用於配置在使用共享記憶體對映(MMAP)方式時的檔名模板。這個選項在某些情況下可以用於解決作業系統限制或者提高效能。預設情況下,這個選項為空,APCu會使用系統預設的檔名模板。設定apc.mmap_file_mask時,你可以使用一些特殊的佔位符來指定檔名的格式,例如%s代表共享記憶體識別符號的十六進位制表示,%p代表當前程序的PID(程序識別符號)。這樣可以確保每個程序使用不同的檔名,避免衝突。一般情況下,你不需要手動設定這個選項,除非你遇到共享記憶體對映方面的特定問題或者有特殊需求。在大多數情況下,使用預設設定即可滿足需求
apc.slam_defense int 1 防止快取雪崩,多程序下,每個程序都試圖同時快取同一個檔案。此選項設定跳過嘗試快取未快取檔案的程序的百分比。或者將其視為單個程序跳過快取的機率。例如,設定為75意味著該程序有75%的機率不會快取未快取的檔案。因此,設定越高,對快取雪崩的防禦就越強。將此項設定為0禁用此功能
apc.enable_cli int 0 是否在cli模式下啟用apc,實測不生效
apc.use_request_time int 0 置控制是否APC應該使用請求時間來為檔案加上時間戳。當啟用時,它可以確保在請求時間變化時重新整理快取檔案,這在某些情況下會很有用,比如在開發或除錯程式碼時
apc.serializer string php 用於配置APC序列化方式。
apc.coredump_unmap int 0 啟用APC處理訊號,如SIGSEGV,該訊號在收到訊號時寫入核心檔案。當收到這些訊號時,APC將嘗試取消共享記憶體段的對映,以便將其從核心檔案中排除。當接收到致命訊號並且配置了大型APC共享記憶體段時,此設定可以提高系統穩定性
apc.preload_path string null 用於指定要預載入的PHP檔案或目錄的路徑。預載入可以提高應用程式的效能,因為它可以在應用程式啟動時將指定的檔案或目錄載入到記憶體中,從而減少了每次請求時的檔案讀取和解析時間。

使用

設定值,注意,快取有值的情況下無法設定值,類比Redis的setnx,型別支援標量、陣列、與物件,這一點非常好。
bool  apcu_add(key, val, ttl);

獲取快取,獲取不到返回false,併發情況下容易返回false
mixed apcu_fetch(key);

樂觀鎖機制,在舊值的基礎上新增新的值
bool apcu_cas(key, int_old, int_new):

清除所有快取
bool apcu_clear_cache()

遞減,引數2支援負數
int apcu_dec(key, 遞減值, 函式返回結果賦值給變數, ttl秒)

從快取中刪除某個元素
bool|array apcu_delete(array|string key)

判斷當前環境能否使用apcu
bool apcu_enabled()

若key不存在,則呼叫callback,並帶有一個預設引數,即key的值
null apcu_entry(key, callback, ttl)

判斷多個key或者單個key是否存在。當引數為array時,函式返回只存在的key組成的陣列
bool|array apcu_exists(string|array key)

獲取某個key的值,若引數1是陣列,那麼結果也是個陣列,只會返回存在的key的值,若key有值引數2為true,否則反之。
bool|array apcu_fetch(array|string key, $var);

遞增,引數2支援負數
int apcu_inc(key, 遞增值, 函式返回結果賦值給變數, ttl秒)

將key的值儲存快取,類比Redis的set,若已存在,可直接替換,引數1也可以傳輸陣列。
bool apcu_store(array|string key, val, ttl)

壓測,對比連線Redis效能

方式 輪次 APCu耗時(秒) Redis耗時(秒)
只讀 10000 0.011 1.162
只寫 10000 0.012 1.062
讀寫,一次new Redis 10000 0.011 2.117
讀寫,多次new Redis 10000 0.011 3.646
只讀(APCu):
<?php

$start = microtime(true);
for($i = 0; $i < 10000; $i++) {
    $key = 'apcu'. $i;
    apcu_fetch($key);
}

echo microtime(true) - $start;


只讀(Redis):
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$start = microtime(true);
for($i = 0; $i < 10000; $i++) {
    $key = 'redis' . $i;
    $redis->get($key);
}

echo microtime(true) - $start;


只寫(APCu):
<?php

$start = microtime(true);
for($i = 0; $i < 10000; $i++) {
    $key = 'apcu'. $i;
    apcu_add($key, $i);
}

echo microtime(true) - $start;


只寫(Redis):
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$start = microtime(true);
for($i = 0; $i < 10000; $i++) {
    $key = 'redis' . $i;
    $redis->set($key, $i);
}

echo microtime(true) - $start;


讀寫,一次new Redis(APCu):
<?php

$start = microtime(true);
for($i = 0; $i < 10000; $i++) {
    $key = 'apcu'. $i;
    apcu_add($key, $i);
    apcu_fetch($key);
}

echo microtime(true) - $start;


讀寫,一次new Redis(Redis):
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$start = microtime(true);
for($i = 0; $i < 10000; $i++) {
    $key = 'redis' . $i;
    $redis->set($key, $i);
    $redis->get($key);
}

echo microtime(true) - $start;


讀寫,多次new Redis(APCu):
<?php

$start = microtime(true);
for($i = 0; $i < 10000; $i++) {
    $key = 'apcu'. $i;
    apcu_add($key, $i);
    apcu_fetch($key);
}

echo microtime(true) - $start;


讀寫,多次new Redis(Redis):
<?php
$start = microtime(true);
for($i = 0; $i < 10000; $i++) {
	$redis = new Redis();
	$redis->connect('127.0.0.1', 6379);
    $key = 'redis' . $i;
    $redis->set($key, $i);
    $redis->get($key);
}

高併發下對APCu原子性測試

壓測工具用ApiPOST,我認為比ab工具好用。
壓測前,為了保證ApiPOST壓測引數(壓測輪次 * 併發數 結果積)的準確性,特地用Redis做了多次測試,發現引數是對的,併發數大了就不對(150以上),這意味著壓測工具應該沒問題,只是裝置執行緒數不夠。

<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->decr('test_key');

多條APCu語句執行能才能測試更能充分原子性,併發100,測試10輪次,也就是1000次請求,但是多次壓測下來,結果不對。apcu_fetch獲取值動不動就是false,導致結果重新賦值為10000(在不存在的情況下賦初始值),有併發問題,但不是因為併發引起的,而是因為apcu_fetch函式的問題,獲取不到值返回false。

<?php
$key = 'test_key';

$res = apcu_fetch($key);
if($res === false) {
    apcu_add($key, 10000);
} else {
    apcu_delete($key);
    apcu_add($key, $res - 1);
}

echo apcu_fetch($key);

相關文章