2

I'm using a nodejs+express server to deploy a website. On that website, I'll have hundreds of videos (mp4) that I want my users to load and view.

Right now, I'm delivering the videos by putting them into the public directory of node, so the data is piped through node and express. I'm wondering whether that practice is alright, or if I should set up a separate apache webserver to deliver the videos. How does the performance compare? What else should be taken into consideration, like caching?

I've tried to find data on that, but was not successful. I see that some people are indeed streaming videos using node (eg here), but I haven't found performance comparisons. I kind of expect there not to be too much difference because the server just has to read and then output the file contents, and the I/O operations should happen at a similar speed. Am I forgetting something?

Thanks a lot!

Community
  • 1
  • 1
Micha Schwab
  • 786
  • 1
  • 6
  • 21

3 Answers3

3

Thats sounds like quite a big video service. If you will have many users in many different locations viewing your videos and you are worried about user experience, then you may want to use some sort of CDN service.

In case you are not familiar, these effectively cache a copy of your content near the 'edge' so users in locations distant from your server are not delayed. They tend to dynamically adjust to cater for more and less popular videos.

You still need an origin server, which is the one you have described above - but now once a user in a particular area has accessed the video, it should be cached in that area so the next visitor will not need to load your server.

There are many CDN networks available and there are even some node.js specific modules to help use them (although you could do it yourself) - e.g.:

Mick
  • 24,231
  • 1
  • 54
  • 120
  • Thanks, that's a good thought and I hadn't thought of using CDNs yet. At least for the first year of this service the users are all located in the same city, so I think I don't quite need it yet (but will keep that in mind when we expand). Other than that, does it matter if I use apache or node? – Micha Schwab Jun 03 '15 at 16:38
  • 1
    I'm not sure you'll find an absolute answer to the node vs apache for streaming question, and you will probably get into a lot of partisan debate, but there are some discussions which suggest that node is not ideal serving large static files: http://stackoverflow.com/q/6634299/334402 (see the top answer but read the others also for some other opinions/inputs). I think a CDN may still be the way to go even if your users are in one area given the size of your service. – Mick Jun 03 '15 at 21:24
0

Mick's answer is right. For scaling, server shoud outsource the caching.

But if you need to benchmark single server caching, check below code that uses only core modules of nodejs. Caching is important but only if network bandwidth is on the order of gigabytes per second and if hdd is too slow. Following code streams nearly 2GB/s from cache hit and still overlaps all cache misses as asynchronous loads on event/message queue.


const cache = require("./simplefastvideostreamcache.js").generateVideoCache; 
const chunkSize = 1024*1024; // size (in bytes) of each video stream chunk
const numCachedChunks = 100; // total chunks cached (shared for all video files accessed)
const chunkExpireSeconds = 100; // when a chunk not accessed for 100 seconds, it is marked as removable
const perfCountObj={}; // just to see performance of cache (total hits and misses where each miss resolves into a hit later so hits = miss + cache hit)
setInterval(function(){console.log(perfCountObj);},1000);

const video = cache(chunkSize,numCachedChunks,chunkExpireSeconds, perfCountObj)

const http = require('http'); 
const options = {};
options.agent = new http.Agent({ keepAlive: true });

const server = http.createServer(options,async (req, res) => {                  
    video.stream(req,res);
});

server.listen(8000, "0.0.0.0", () => {
  console.log("Server running");
});

simplefastvideostreamcache.js:


const urlParse = require("url");
const fs = require("fs");
const path = require("path");
const Lru = require("./lrucache").Lru;
const stream = require('stream').Readable;
function generateVideoCache(chunkSize,numCachedChunks,chunkExpireSeconds, perfCountObj)
{
    perfCountObj.videoCacheMiss=0;
    perfCountObj.videoCacheHit=0;
    let videoCache={chunkSize:chunkSize};
    videoCache.cache= new Lru(numCachedChunks, function(key,callbackPrm){
                        perfCountObj.videoCacheMiss++;
                        let callback = callbackPrm;
                        
                        let data=[];
                        let keyArr = key.split("##@@##");
                        let url2 = keyArr[0];
                        let startByte = parseInt(keyArr[1],10);
                        let stopByte = startByte+videoCache.chunkSize;

                        fs.stat(path.join(__dirname,url2),async function(err,stat){
                            if(err)
                            {
                                callback({data:[], maxSize:-1, startByte:-1, stopByte:-1});
                                return;
                            }
                            
                            if(stopByte > stat.size)
                            {
                                stopByte = parseInt(stat.size,10);
                            }

                            if(startByte >= stopByte)
                            {
                                callback({data:[], maxSize:-1, startByte:-1, stopByte:-1});
                                return;
                            }
                            
                                                            
                            let readStream=fs.createReadStream(path.join(__dirname,url2),{start:startByte, end:stopByte});
                            readStream.on("readable",function(){
                                let dataChunk =""; 
                                while(data.length<(stopByte-startByte))
                                {
                                    let dataChunk = readStream.read((stopByte-startByte) - data.length);
                                    if(dataChunk !== null)
                                    {
                                        data.push(dataChunk);
                                    }
                                    else
                                    {
                                        break;
                                    }
                                }

                            });
                            readStream.on("error",function(err){ 
                                callback({data:[], maxSize:-1, startByte:-1, stopByte:-1});
                                return; 
                            });
                            readStream.on("end",function(){  
                                callback({data:Buffer.concat(data), maxSize:stat.size, startByte:startByte, stopByte:stopByte});
                            }); 
                        });         
    },chunkExpireSeconds*1000);

    videoCache.get = function(filePath, offsetByte,callback){
        filePath = decodeURI(urlParse.parse(filePath).pathname);
        let rangeStart = offsetByte;
        let rangeStop = videoCache.chunkSize; 
        if(rangeStart)
        {

        }
        else
        {
            rangeStart=0;
        }

        if(rangeStop)
        {

        }
        else
        {
            rangeStop = rangeStart + videoCache.chunkSize;
        }
                            
        let dataVideo = [];
        let cacheStart = rangeStart - (rangeStart%videoCache.chunkSize);
        videoCache.cache.get(filePath+"##@@##"+cacheStart,function(video){
            perfCountObj.videoCacheHit++;
            if(video.startByte>=0)
            {
                let offs = rangeStart%videoCache.chunkSize;
                let remain = videoCache.chunkSize - offs;
                if(remain>video.maxSize)
                    remain = video.maxSize;
                if(remain>video.data.length)
                    remain=video.data.length;
                let vidChunk = video.data.slice(offs,offs+remain);
                if(remain>vidChunk.length)
                    remain=vidChunk.length;
                let result={ data:vidChunk, offs:rangeStart, remain:remain, maxSize:video.maxSize};
                callback(result);
                return;
            }
            else
            {
                callback(false);
                return;
            }                               
        });
    };
    videoCache.stream = function(req,res){
        let url2 = decodeURI(urlParse.parse(req.url).pathname);
        let rangeStart = 0;
        let rangeStop = videoCache.chunkSize; 
        if(req.headers.range)
        {
            let spRange = req.headers.range.split("=");
            if(spRange.length>1)
            {
                let spRange2 = spRange[1].split("-");
                if(spRange2.length>1)
                {
                    rangeStart = parseInt(spRange2[0],10);
                    rangeStop = parseInt(spRange2[1],10);
                }
                else if(spRange2.length==1)
                {
                    rangeStart = parseInt(spRange2[0],10);
                    rangeStop = rangeStart + videoCache.chunkSize;
                }
            }
                    
        }
                                                    
        if(rangeStart)
        {

        }
        else
        {
            rangeStart=0;
        }

        if(rangeStop)
        {

        }
        else
        {
            rangeStop = rangeStart + videoCache.chunkSize;
        }
                            
        let dataVideo = [];
        let cacheStart = rangeStart - (rangeStart%videoCache.chunkSize);
        /* {data:[], maxSize:stat.size, startByte:-1, stopByte:-1} */
                            
        videoCache.cache.get(url2+"##@@##"+cacheStart,function(video){
            if(video.startByte>=0)
            {
                let offs = rangeStart%videoCache.chunkSize;
                let remain = videoCache.chunkSize - offs;
                if(remain>video.maxSize)
                    remain = video.maxSize;
                if(remain>video.data.length)
                    remain=video.data.length;
                let vidChunk = video.data.slice(offs,offs+remain);
                if(remain>vidChunk.length)
                    remain=vidChunk.length;
                            
                res.writeHead(206,{
                    "Content-Range": "bytes " + rangeStart + "-" + (rangeStart+remain-1) + "/" + video.maxSize,
                    "Accept-Ranges": "bytes",
                    "Content-Length": remain,
                    "Content-Type": ("video/"+(url2.indexOf(".mp4")!== -1 ? "mp4" : "ogg"))
                });
                                        

                perfCountObj.videoCacheHit++;
                stream.from(vidChunk).pipe(res);
                return;
            }
            else
            {
                res.writeHead(404);
                perfCountObj.videoCacheHit++;                                   
                res.end("404: mp4/ogg video file not found.");
                return;
            }                               
        });
    }
    return videoCache;
}

exports.generateVideoCache = generateVideoCache;


lrucache.js:


'use strict';

/* 
cacheSize: number of elements in cache, constant, must be greater than or equal to number of asynchronous accessors / cache misses
callbackBackingStoreLoad: user-given cache-miss function to load data from datastore
elementLifeTimeMs: maximum miliseconds before an element is invalidated, only invalidated at next get() call with its key
*/

let Lru = function(cacheSize,callbackBackingStoreLoad,elementLifeTimeMs=1000){
    const me = this;
    
    const maxWait = elementLifeTimeMs;
    const size = parseInt(cacheSize,10);
    const mapping = {};
    const mappingInFlightMiss = {};
    const bufData = new Array(size);
    const bufVisited = new Uint8Array(size);
    const bufKey = new Array(size);
    const bufTime = new Float64Array(size);
    const bufLocked = new Uint8Array(size);
    for(let i=0;i<size;i++)
    {
        let rnd = Math.random();
        mapping[rnd] = i;
        
        bufData[i]="";
        bufVisited[i]=0;
        bufKey[i]=rnd;
        bufTime[i]=0;
        bufLocked[i]=0;
    }
    let ctr = 0;
    let ctrEvict = parseInt(cacheSize/2,10);
    const loadData = callbackBackingStoreLoad;
    let inFlightMissCtr = 0;
    this.reload=function(){
        for(let i=0;i<size;i++)
        {
            bufTime[i]=0;
        }
    };
    this.get = function(keyPrm,callbackPrm){
        const key = keyPrm;
        const callback = callbackPrm;
        
        // stop dead-lock when many async get calls are made
        if(inFlightMissCtr>=size)
                {
                    setTimeout(function(){
                me.get(key,function(newData){
                    callback(newData);
                });
            },0);
                    return;
            }
        
        // delay the request towards end of the cache-miss completion
        if(key in mappingInFlightMiss)
        {

            setTimeout(function(){
                me.get(key,function(newData){
                    callback(newData);
                });
            },0);
            return;
        }

        if(key in mapping)
        {
            let slot = mapping[key];
            // RAM speed data
            if((Date.now() - bufTime[slot]) > maxWait)
            {
                
                if(bufLocked[slot])
                {                                       
                    setTimeout(function(){
                        me.get(key,function(newData){
                            callback(newData);
                        });
                    },0);
                    
                }
                else
                {
                    delete mapping[key];
                    
                    me.get(key,function(newData){
                        callback(newData);
                    });
                    
                }
                
            }
            else
            {
                bufVisited[slot]=1;
                bufTime[slot] = Date.now();
                callback(bufData[slot]);
            }
        }
        else
        {
            // datastore loading + cache eviction
            let ctrFound = -1;
            while(ctrFound===-1)
            {
                // give slot a second chance before eviction
                if(!bufLocked[ctr] && bufVisited[ctr])
                {
                    bufVisited[ctr]=0;
                }
                ctr++;
                if(ctr >= size)
                {
                    ctr=0;
                }

                // eviction conditions
                if(!bufLocked[ctrEvict] && !bufVisited[ctrEvict])
                {
                    // evict
                    bufLocked[ctrEvict] = 1;
                    inFlightMissCtr++;
                    ctrFound = ctrEvict;
                }

                ctrEvict++;
                if(ctrEvict >= size)
                {
                    ctrEvict=0;
                }
            }
            
            mappingInFlightMiss[key]=1;
            let f = function(res){
                delete mapping[bufKey[ctrFound]];

                bufData[ctrFound]=res;
                bufVisited[ctrFound]=0;
                bufKey[ctrFound]=key;
                bufTime[ctrFound]=Date.now();
                bufLocked[ctrFound]=0;

                mapping[key] = ctrFound;
                callback(bufData[ctrFound]);
                inFlightMissCtr--;
                delete mappingInFlightMiss[key];        
            };
            loadData(key,f);

        }
    };
};
exports.Lru = Lru;


huseyin tugrul buyukisik
  • 11,469
  • 4
  • 45
  • 97
0

There are a few answers here but one thing that should be pointed out is that you really shouldn't store the videos on the same server..

Reason being is that, as I'm sure you are aware.. Node is a single thread ( Yes, it has multi threading for some things and child processes etc ) but the event loop is a single thread and as such clustering is usually used at scale to increase the response time etc.

If you plan on clustering your setup, or even putting it into a container then the ephemeral storage is not the best place because its not guaranteed the file will be there (unless it's placed on every server), instead you probably want to look at an object store like s3 ( there are many options here Linode, Digital ocean etc )

Doing this will allow you to serve them through a dedicated media URL like media.domain.com/video, then you don't have to worry about any IO, then, as other have stated, place a CDN in front of it to help with egress cost.

This also brings up another questions as to how you plan on sending the data, if its just a mp4 the browser, by default will kind of chunk it so it will start playing before its done downloading but the user still will request the full payload.. This can be costly at scale, so if you inten is to 'stream' then you may want to look at a media server that can stream the file VS download the whole thing at once.

proxim0
  • 1,418
  • 2
  • 11
  • 14