[fix]포인트 사용/환원 함수의 race condition 수정

insert_use_point, delete_use_point, delete_expire_point 함수의 SELECT-then-UPDATE
패턴이 동시 호출 시 데이터 무결성을 깰 수 있는 race condition을 가지고 있었음.
같은 회원의 포인트 작업이 동시에 발생하면 PHP 단에서 캐시한 잔여량을 기준으로
판단하여 결과적으로 음수/초과 차감 등 포인트가 꼬이는 현상이 발생.

MyISAM은 트랜잭션과 FOR UPDATE를 지원하지 않으므로, 락 없이 무결성을 보장하는
lock-free atomic UPDATE 패턴으로 재구현:

- 매 단계마다 가장 우선 처리할 행 1개를 SELECT (LIMIT 1)
- WHERE 절에 사전 검증 조건(잔여량/상태)을 포함한 원자적 UPDATE
- get_sql_affected_rows()로 성공/실패 판별, 실패 시 재시도
- max_iter=1000 안전장치로 무한루프 방지

특징:
- 단일 행 차감(가장 흔한 케이스)에서 기존과 동일한 쿼리 수
- GET_LOCK 등 추가 락 없음 → 같은 사용자 동시 요청도 throughput 손실 없음
- MyISAM/InnoDB 모두 호환, MySQL 5.0~8.x 모두 동작
This commit is contained in:
thisgun committed 2026-04-16 01:35:45 +00:00
1 parent 4d5c597665
commit 4ebdd8ba30
1 file changed
+150 -71
+150 -71
View File
@@ -1154,43 +1154,71 @@ function insert_point($mb_id, $point, $content='', $rel_table='', $rel_id='', $r
function insert_use_point($mb_id, $point, $po_id='')
{
global $g5, $config;
if ($replace_insert = run_replace('insert_use_point_before', '', $mb_id, $point, $po_id)) {
return $replace_insert;
}
// 레이스 컨디션 방지: 매 단계마다 가장 오래된 행 1개를 SELECT한 후
// WHERE 절에 사전 검증 조건을 포함한 원자적 UPDATE로 차감한다.
// affected_rows로 성공/실패를 판별하고 실패 시 재시도하므로 락 없이 무결성 보장.
// (MyISAM/InnoDB 모두 호환, FOR UPDATE 불필요)
if($config['cf_point_term'])
$sql_order = " order by po_expire_date asc, po_id asc ";
else
$sql_order = " order by po_id asc ";
$point1 = abs($point);
$sql = " select po_id, po_point, po_use_point
from {$g5['point_table']}
where mb_id = '$mb_id'
and po_id <> '$po_id'
and po_expired = '0'
and po_point > po_use_point
$sql_order ";
$result = sql_query($sql);
for($i=0; $row=sql_fetch_array($result); $i++) {
$point2 = $row['po_point'];
$point3 = $row['po_use_point'];
$remaining = abs($point);
$max_iter = 1000; // 무한루프 안전장치 (정상 케이스는 차감 행 수만큼만 반복)
if(($point2 - $point3) > $point1) {
while ($remaining > 0 && $max_iter-- > 0) {
// 사용 가능한 가장 오래된 행 1개 조회
$sql = " select po_id, po_point, po_use_point
from {$g5['point_table']}
where mb_id = '$mb_id'
and po_id <> '$po_id'
and po_expired = '0'
and po_point > po_use_point
$sql_order
limit 1 ";
$row = sql_fetch($sql);
if (!$row || empty($row['po_id'])) {
break; // 더 이상 사용 가능한 포인트 없음
}
$available = $row['po_point'] - $row['po_use_point'];
if ($available > $remaining) {
// 이 행만으로 충분 (부분 차감)
// WHERE 절에서 "현재도 충분한 잔여가 있는가"를 원자적으로 재검증
$sql = " update {$g5['point_table']}
set po_use_point = po_use_point + '$point1'
where po_id = '{$row['po_id']}' ";
set po_use_point = po_use_point + '$remaining'
where po_id = '{$row['po_id']}'
and po_expired = '0'
and (po_point - po_use_point) > '$remaining' ";
sql_query($sql);
break;
if (get_sql_affected_rows() > 0) {
$remaining = 0;
break;
}
// 0건 = 다른 프로세스가 이 행을 먼저 차감 → 다시 SELECT로 재시도
} else {
$point4 = $point2 - $point3;
// 이 행을 완전 소진 + expired 마킹
// WHERE 절에서 SELECT 시점의 po_use_point 값과 일치할 때만 UPDATE
$consume = $available;
$sql = " update {$g5['point_table']}
set po_use_point = po_use_point + '$point4',
set po_use_point = po_use_point + '$consume',
po_expired = '100'
where po_id = '{$row['po_id']}' ";
where po_id = '{$row['po_id']}'
and po_use_point = '{$row['po_use_point']}'
and po_expired = '0' ";
sql_query($sql);
$point1 -= $point4;
if (get_sql_affected_rows() > 0) {
$remaining -= $consume;
}
// 0건 = 다른 프로세스가 이 행을 먼저 변경 → 다시 SELECT로 재시도
}
}
}
@@ -1200,41 +1228,68 @@ function delete_use_point($mb_id, $point)
{
global $g5, $config;
// 레이스 컨디션 방지: 매 단계마다 사용된 가장 최근 행 1개를 조회한 후
// WHERE 절에 SELECT 시점의 상태를 검증하는 원자적 UPDATE로 환원한다.
if($config['cf_point_term'])
$sql_order = " order by po_expire_date desc, po_id desc ";
else
$sql_order = " order by po_id desc ";
$point1 = abs($point);
$sql = " select po_id, po_use_point, po_expired, po_expire_date
from {$g5['point_table']}
where mb_id = '$mb_id'
and po_expired <> '1'
and po_use_point > 0
$sql_order ";
$result = sql_query($sql);
for($i=0; $row=sql_fetch_array($result); $i++) {
$point2 = $row['po_use_point'];
$remaining = abs($point);
$max_iter = 1000;
$po_expired = $row['po_expired'];
if($row['po_expired'] == 100 && ($row['po_expire_date'] == '9999-12-31' || $row['po_expire_date'] >= G5_TIME_YMD))
$po_expired = 0;
if($point2 > $point1) {
$sql = " update {$g5['point_table']}
set po_use_point = po_use_point - '$point1',
po_expired = '$po_expired'
where po_id = '{$row['po_id']}' ";
sql_query($sql);
while ($remaining > 0 && $max_iter-- > 0) {
// 사용분이 있는 가장 최근 행 1개 조회
$sql = " select po_id, po_use_point, po_expired, po_expire_date
from {$g5['point_table']}
where mb_id = '$mb_id'
and po_expired <> '1'
and po_use_point > 0
$sql_order
limit 1 ";
$row = sql_fetch($sql);
if (!$row || empty($row['po_id'])) {
break;
}
$row_used = $row['po_use_point'];
// 만료 마커(100) 복구 여부 결정
$po_expired_new = $row['po_expired'];
if ($row['po_expired'] == 100 && ($row['po_expire_date'] == '9999-12-31' || $row['po_expire_date'] >= G5_TIME_YMD))
$po_expired_new = 0;
if ($row_used > $remaining) {
// 부분 환원
// WHERE에 현재 po_use_point가 차감 가능한 수준인지와 expired 상태를 함께 검증
$sql = " update {$g5['point_table']}
set po_use_point = po_use_point - '$remaining',
po_expired = '$po_expired_new'
where po_id = '{$row['po_id']}'
and po_use_point >= '$remaining'
and po_expired = '{$row['po_expired']}' ";
sql_query($sql);
if (get_sql_affected_rows() > 0) {
$remaining = 0;
break;
}
// 0건 = 다른 프로세스가 이 행을 먼저 변경 → 재시도
} else {
// 사용분 전체 환원
$consume = $row_used;
$sql = " update {$g5['point_table']}
set po_use_point = '0',
po_expired = '$po_expired'
where po_id = '{$row['po_id']}' ";
po_expired = '$po_expired_new'
where po_id = '{$row['po_id']}'
and po_use_point = '{$row['po_use_point']}'
and po_expired = '{$row['po_expired']}' ";
sql_query($sql);
$point1 -= $point2;
if (get_sql_affected_rows() > 0) {
$remaining -= $consume;
}
// 0건 = 다른 프로세스가 이 행을 먼저 변경 → 재시도
}
}
}
@@ -1244,39 +1299,63 @@ function delete_expire_point($mb_id, $point)
{
global $g5, $config;
$point1 = abs($point);
$sql = " select po_id, po_use_point, po_expired, po_expire_date
from {$g5['point_table']}
where mb_id = '$mb_id'
and po_expired = '1'
and po_point >= 0
and po_use_point > 0
order by po_expire_date desc, po_id desc ";
$result = sql_query($sql);
for($i=0; $row=sql_fetch_array($result); $i++) {
$point2 = $row['po_use_point'];
$po_expired = '0';
// 레이스 컨디션 방지: 매 단계마다 소멸된 가장 최근 행 1개를 조회한 후
// WHERE 절에 SELECT 시점의 상태를 검증하는 원자적 UPDATE로 환원한다.
$remaining = abs($point);
$max_iter = 1000;
while ($remaining > 0 && $max_iter-- > 0) {
// 소멸 처리된 가장 최근 행 1개 조회
$sql = " select po_id, po_use_point, po_expired, po_expire_date
from {$g5['point_table']}
where mb_id = '$mb_id'
and po_expired = '1'
and po_point >= 0
and po_use_point > 0
order by po_expire_date desc, po_id desc
limit 1 ";
$row = sql_fetch($sql);
if (!$row || empty($row['po_id'])) {
break;
}
$row_used = $row['po_use_point'];
$po_expire_date = '9999-12-31';
if($config['cf_point_term'] > 0)
if ($config['cf_point_term'] > 0)
$po_expire_date = date('Y-m-d', strtotime('+'.($config['cf_point_term'] - 1).' days', G5_SERVER_TIME));
if($point2 > $point1) {
if ($row_used > $remaining) {
// 부분 환원
$sql = " update {$g5['point_table']}
set po_use_point = po_use_point - '$point1',
po_expired = '$po_expired',
set po_use_point = po_use_point - '$remaining',
po_expired = '0',
po_expire_date = '$po_expire_date'
where po_id = '{$row['po_id']}' ";
sql_query($sql);
break;
} else {
$sql = " update {$g5['point_table']}
set po_use_point = '0',
po_expired = '$po_expired',
po_expire_date = '$po_expire_date'
where po_id = '{$row['po_id']}' ";
where po_id = '{$row['po_id']}'
and po_expired = '1'
and po_use_point >= '$remaining' ";
sql_query($sql);
$point1 -= $point2;
if (get_sql_affected_rows() > 0) {
$remaining = 0;
break;
}
// 0건 = 다른 프로세스가 이 행을 먼저 변경 → 재시도
} else {
// 사용분 전체 환원
$consume = $row_used;
$sql = " update {$g5['point_table']}
set po_use_point = '0',
po_expired = '0',
po_expire_date = '$po_expire_date'
where po_id = '{$row['po_id']}'
and po_expired = '1'
and po_use_point = '{$row['po_use_point']}' ";
sql_query($sql);
if (get_sql_affected_rows() > 0) {
$remaining -= $consume;
}
// 0건 = 다른 프로세스가 이 행을 먼저 변경 → 재시도
}
}
}