EURO2024 - Attaining 800 TPS
In EURO2024 project the Project was planned to achieve high traffic throughput as of the Peak performance requirement would be seasonal by its nature. (People tends to come in and purchase postcards when the last match was about to kick).
The Challenge
Now the first page is the most important one. We expand our audiences from just PromptPost Application to also PaoTang's application hence the first API that serve the first page is most critical.
In this API it contains only 1 API call which fetch
- the available team
- the latest postcard serials.
The data itself is quite static therefore it would be easy if we cache it via API Gateway.
Hence if it the cache is missed it will fall through to Lambda and Lambda can serve the request by query it from original sources.
Cache Missed
+--------+ +--------+ +-----+
User --> | API Gw | ------> | Lambda | ------> | RDS |
+--------+ +--------+ +-----+
REQ Query
Cache Hit
+--------+
User --> | API Gw |
+--------+
Originally we designed a Controller endpoint that offer this API called master
API and cache it via API Gateway.
The endpoint designed using redis.once()
type of call where it will ask Redis first if the data is available; If so, use redis. Otherwise fetch it from RDS.
const founds = await this.cache().once(CACHE_KEY_CAMPAIGN_VARIANTS(campaignId), async () => {
const res = await DI.em.find(
CampaignVariantEntity,
{
campaign: { campaignId },
},
{
orderBy: {
label: {
th: 'ASC',
},
},
},
)
return res.map((f) => CampaignVariantEntity.toDto(f))
})
This seems to work really well on Dev. It fast, it doesn't have complicated states to maintain Redis is merely a cache. RDS is still back up for the case of cache miss. All good!
The Problem
However there is still a problem. Let say if Lambda took 300ms
to fetch these 2 data points. a 100 requests per seconds traffic means that there will be on average 30 lambda invocation spikes per Cache Missed. And with 30 lambda invocation it will also floor the RDS proxy client connection as well. The problem seems to be a bit harder than expected.
- Lambda Inovocation spike.
- Each Lambda will increase client connections as our application like Koa doesn't know if the invocation need to use RDS or not. It will just invoke it.
- Lambda with Application is heavy.
- To make this worse. The ColdStart of Lambda will cause those
300ms
to4000ms
.
So to fix this we will need a lot ligher, and faster Lambda execution.
The Solution
What we endup doing is 3 folds.
- Make it light-weight - Write a new stand alone Lambda handler.
- Drop the RDS - new Lambda only read from Redis.
- Cache it on API Gateway for short period of time.
Impelmentation wise this is very simple Lambda handler, No Koa application, No RDS connection.
However we will need to update how our system treat redis. From now redis is a persistent data. When RDS changed. It will need to cascade the changes to Redis.
With all those changes; We can now end up write a new stand alone Lambda handler like so.
// cached.ts
export const handler: Handler = async function (event, context, callback) {
context.callbackWaitsForEmptyEventLoop = false
const awsRequestId = context.awsRequestId
const path: string = event.path
const method = event.method
const prefix = `[EVT] ${awsRequestId} ${method} ${path}`
const campaignId = `${event.headers[HEADER_KEY_CAMPAIGN_ID] || HEADER_DEFAULT_CAMPAIGN_ID}`
console.log(prefix, 'INFO', campaignId)
try {
let statusCode = 200
let result = {}
if (path.endsWith('front-page')) {
if (!cacheInst) {
console.log('Initiating cache')
cacheInst = RedisService.auto('thaipost-postcard', false)
}
const repo = new CachedRepository(() => cacheInst)
result = await repo.getFrontPage(campaignId)
} else {
statusCode = 404
result = {
error: 'not found',
}
}
callback(null, {
statusCode,
headers: { /* cors */
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': ALLOWED_HEADERS,
},
body: JSON.stringify({ success: statusCode === 200, data: result }),
})
} catch (e) {
console.error(prefix, 'ERROR', e)
callback(null, {
statusCode: +(e && (e as any)).statusCode || 400,
headers: { /* cors */
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': ALLOWED_HEADERS,
},
body: JSON.stringify({
success: false,
error: e && (e as Error).message,
}),
})
}
}
Final Tip
- To make lambda light weight. Do not import
index.ts
for package to named import it. This only make sense if your JS bundle need a light weight. But for the full application like Koa that include everything in single Lambda it import fromindex.ts
will be much easier to organized.
Example
Light weight for sure.
import { serialCode } from '../utils/serial' // this is gurantee to be lesser code bundled for sure.
Bundler if configure correctly may help you do this.
import { serialCode } from '../utils' // this will depends on TreeShaker and ESM module to do the job.