글로벌 서비스 분산 처리, 레거시 스타일 프로그래밍
작년에 진행된 글로벌 프로젝트에서, 설계가 잘못되어 대안을 제시해야 했다. 우선, 작업 환경은 다음과 같다.
- 주 언어는 PHP 다.
- 개발자들은 Amazon Web Service 의 활용/사용 방법을 잘 모른다. (때문에 API Gateway, Lambda 를 쓸 수 없었다)
분산된 지역에서 단일화된 처리 중 DynamoDB 는 매우 좋은 대안이다. Global Table을 사용하면, 각 Region의 Relica를 Master 로 사용할 수 있다. 이를 감안하고 인프라를 설계했으나, 서비스 직전 확인된 내용으로 Seoul DynamoDB 가 Global Table을 지원하지 않음을 발견했다. (현재는 Global Table을 지원함) 때문에 대안을 제시해야 했다.
비록 1시간만에 만들어진 날림 문서지만. 나중에 유사 케이스를 대비해.
주어진 시간이 매우 짧았기 때문에 구체적이면서 현실적 제안이 필요했다.
목적
애시당초 사용하고자 했던 DynamoDB를 사용할 수 없게 됨에 따라 차선책을 마련한다.
- 사유 : Global Tables Region 에 SEOUL 이 제외되어 있기 때문
주요 고려사항
- 특정 지역의 RDS/MySQL(Master=Tokyo)를 직접 Access 하면 투표정보 처리시 병목현상이 발생할 수 있다. =Too many connection 등
- 사용자의 요청이 직접 다른 지역으로 전달되면 상대적으로 긴 Latency 를 갖게 된다. = A 지역 서버에서 B 지역서버를 CALL 해야 함
- 정해진 값이 범위내에서 반복적으로 사용 되는 경우 로컬 리소스를 사용함으로서 성능을 극대화 한다. = Shared Memory 제안
흐름
“모든 처리는 TOKYO, API Server 가 중심이다”
- “로그인(SELECT)” 처리는 MySQL 에서 확인한다. 단, 신규(갱신) 사용자는 TOKYO에 전달 한다. (INSERT/UPDATE)
- 사용자가 투표를 하고자 할 때 Local Shared Memory 데이터를 대조한다.
- 각 Region 에는 HA 구성이 되어 있으므로, 최대 n x 2 의 경우의 수 존재
- A < 5 && B = 5 일 수 있다. 사용자가 A 에서 처리 요청하면 API 서버에 요청한다.
API 서버가 확인했을때 이미 5(=B) 이므로 처리 불가이며, 투표 회수를 반환한다.
A = B = 5 로 갱신된다.
- 투표수 < 5 인 경우 API Server 에 처리 요청한다.
- API Server 는 투표 데이터를 Redis 에서 조회하고, 값을 갱신한다. 그리고 결과를 회신한다.
- 사용자는 “투표가 완료 되었습니다” 메시지를 화면에 출력하게 된다.
“배치처리를 통해 투표 현황을 공유한다”
- 투표 기간동안 CRON(Batch) 에 REDIS 의 내용을 RDS/MySQL 에 갱신한다. (API Server)
- MySQL 은 각 Region 의 Read-Relica 에 배포된다.
- 투표 기간동안 CRON(Batch) 에서 투표현황 RDS/MySQL 내용을 Shared Memory 에 옮긴다. (Service Server)
“투표기간동안 API Server 에 부하가 집중된다”
- TOKYO 의 Instance Type 의 Scale 을 한단계 업그레이드 한다 (약 2주간)
FLOW
결정적 이점
- HA 구성 되어 있어도 동기화가 가능 (데이터가 손실되도 무관)
- n >=5 인경우 Central API 를 Request 하지 않음
- n < 5 인 경우 사용자가 Region 이 변경되지 않는다면 이점이 있다
- ‘투표 상태’ 값을 n 분 단위로 갱신하고, 그 내용을 메모리가 갖고 있기 때문에 Page Makeup 이 상당히 빠르다.
예제
타 서비스의 이벤트 내 Shared Memory 응용 예제
// 값의 유효성 검증 $cacheId = @shmop_open(EVENT_SEQ_CACHEID, "a", 0, 0); $cachedStatus = true; $processType = "MEM"; if($cacheId){ $cachedSeq = unserialize(shmop_read($cacheId, 0, shmop_size($cacheId))); shmop_close($cacheId); } if(!$cacheId || !isset($cachedSeq[$seq]) || !is_numeric($cachedSeq[$seq]['comicseq'])){ // 캐시 ID 가 없는경우 (리딩실패) // cachedSeq 가 선언되어 있지 않은경우 (널값) // 정상값으로 보이나, 만화 작화/작품코드가 없는 경우 = 무결성 오류 // 메모리를 읽을 수 없으므로 기존 메모리를 삭제한다 $cacheId = @shmop_open(EVENT_SEQ_CACHEID, "a", 0, 0); @shmop_delete($cacheId); @shmop_close($cacheId); // 재생성 $allData = $this->myGetRows("SELECT mtces_seq, mtces_case, mtces_comicseq, mtces_blocked FROM mt_comic_event_seq_history"); $cachedSeq = array(); for($i=0 ; $i<count($allData) ; $i++){ $cachedSeq[$allData[$i][0]] = array(); $cachedSeq[$allData[$i][0]]['pattern'] = $allData[$i][1]; $cachedSeq[$allData[$i][0]]['comicseq'] = $allData[$i][2]; $cachedSeq[$allData[$i][0]]['status'] = $allData[$i][3]; } if(!isset($cachedSeq[$seq])){ $cachedSeq[$seq] = array(); $cachedSeq[$seq]['pattern'] = ""; $cachedSeq[$seq]['comicseq']= ""; $cachedSeq[$seq]['status'] = "C"; // 없는 시퀀스를 임의로 요청했다. 없는값을 다시 요청했을 때 DB 커넥을 막는다 } $saveData = serialize($cachedSeq); $cacheId = @shmop_open(EVENT_SEQ_CACHEID, "c", 0666, strlen($saveData)); shmop_write($cacheId, $saveData, 0); shmop_close($cacheId); $processType = "RDS"; } $comicSeq = (isset($cachedSeq[$seq]))?$cachedSeq[$seq]['comicseq']:""; if($cachedSeq[$seq]['status'] != '1'){ // 시퀀스 사용 가능여부 1 = 사용가능(allow), 0 = 블럭(block) $errCode = ($cachedSeq[$seq]['status'] == 'B')?"002":"999"; $this->myQry(sprintf($userQry, $seq, "ERR".$errCode, $comicSeq, 0, $processType)); $this->errBack("",$urlCommonErr."/MT.E.0".$errCode); } if($pattern != $cachedSeq[$seq]['pattern']) { // 사용자 요청 패턴이 올바르나 시퀀스내 패턴과 일치하지 않는다 $this->myQry(sprintf($userQry, $seq, "ERR003", $comicSeq, 0, $processType)); $this->errBack("",$urlCommonErr."/MT.E.0003"); } // 봇과 사용자를 구분한다 $processMode = ($botCheck)?"BOTBOT":"SUCCES"; $processFlag = ($botCheck)?3:1; // successvisit if(strpos($_SERVER['HTTP_REFERER'], $_SERVER['HTTP_HOST'])){ $this->myQry(sprintf($userQry, $seq, "INNERS", $comicSeq, 4, $processType)); if(!$_SESSION['EVENT_SEQNO']) $_SESSION['EVENT_SEQNO'] = $seq; } else if($_SESSION['EVENT_SEQNO']) // 현재 세션에서 재요청이 들어온사람 $this->myQry(sprintf($userQry, $seq, "ALLRDY", $comicSeq, 2, $processType)); else { $_SESSION['EVENT_SEQNO'] = $seq; // 정상 사용자이므로 현 세션에 값을 추가한다 $this->myQry(sprintf($userQry, $seq, $processMode, $comicSeq, $processFlag, $processType)); } header('Location: /'.$pattern.'/'.$comicSeq); exit; }
주요코드
코드
|
설명
|
---|---|
$cacheId = @shmop_open(EVENT_SEQ_CACHEID, “a”, 0, 0); | Shared Memory 메모리 내용을 읽는다 (읽기/추가 모드) |
$cacheId = @shmop_open(EVENT_SEQ_CACHEID, “c”, 0666, strlen($saveData)); | Shared Memory 메모리를 기록 가능한 형태로 읽는다. |
shmop_close($cacheId); | Shared Memory 객체를 제거한다 |
shmop_delete($cacheId); | Shared Memory 를 삭제한다 |
shmop_write($cacheId, $saveData, 0); | Shared Memory 에 값을 기록한다 |
※ Shared Memory 에 기록할때 배열 형태로 기록하면 나중에 배열 형태로 그대로 사용할 수 있다.
처리결과
한시간에 약 4천명의 사용자 데이터 처리. (만화 서버는 비용 절감을 위해 HA 구성이 되어 있지 않음)
예) 메모리에 Array 형태로 저장하기 때문에 사용이 편리하다
if(is_numeric($userVoteInfo["[email protected]"]["facebook"])) && $userVoteInfo["[email protected]"]["facebook"] < 5) : echo "투표할 수 있어요"; else : echo "투표할 수 없어요"; endif;
기타
Shared Memory 는 매우 빠르기 때문에 성능 저하가 발생하지 않는다.
정기적으로 값을 갱신하면 신뢰도를 증대시킬 수 있다.